mirror of
https://github.com/wger-project/wger.git
synced 2025-12-30 02:10:57 -06:00
Merge pull request #2136 from Frenzy-code/trophies
Add Trophy System's Backend
This commit is contained in:
123
package-lock.json
generated
123
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -65,6 +65,7 @@ INSTALLED_APPS = [
|
||||
'wger.weight',
|
||||
'wger.gallery',
|
||||
'wger.measurements',
|
||||
# 'wger.trophies',
|
||||
|
||||
# reCaptcha support, see https://github.com/praekelt/django-recaptcha
|
||||
'django_recaptcha',
|
||||
@@ -568,6 +569,10 @@ WGER_SETTINGS = {
|
||||
'USE_CELERY': False,
|
||||
'USE_RECAPTCHA': False,
|
||||
'WGER_INSTANCE': 'https://wger.de',
|
||||
|
||||
# Trophy system settings
|
||||
'TROPHIES_ENABLED': True, # Global toggle to enable/disable trophy system
|
||||
'TROPHIES_INACTIVE_USER_DAYS': 30, # Days of inactivity before skipping trophy evaluation
|
||||
}
|
||||
|
||||
#
|
||||
|
||||
577
wger/trophies/README.md
Normal file
577
wger/trophies/README.md
Normal file
@@ -0,0 +1,577 @@
|
||||
# Trophy System
|
||||
|
||||
The trophy (achievement) system allows users to earn trophies based on
|
||||
their workout activities.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Trophy Types**: Time-based, volume-based, count-based,
|
||||
sequence-based, date-based, and custom trophies
|
||||
- **Progressive Trophies**: Show user progress towards earning a trophy
|
||||
- **Hidden Trophies**: Secret achievements that are revealed only when
|
||||
earned
|
||||
- **Automatic Evaluation**: Trophies are evaluated automatically when
|
||||
workout data changes
|
||||
- **Statistics Tracking**: Denormalized statistics for efficient trophy
|
||||
evaluation
|
||||
- **API Endpoints**: Full REST API for trophy data and progress tracking
|
||||
|
||||
## Configuration
|
||||
|
||||
### Global Settings
|
||||
|
||||
Add to your `settings.py` or `settings_global.py`:
|
||||
|
||||
```python
|
||||
WGER_SETTINGS = {
|
||||
# Enable/disable the trophy system globally
|
||||
'TROPHIES_ENABLED': True,
|
||||
|
||||
# Number of days of inactivity before skipping trophy evaluation for a user
|
||||
'TROPHIES_INACTIVE_USER_DAYS': 30,
|
||||
}
|
||||
```
|
||||
|
||||
### User Preferences
|
||||
|
||||
Users can enable/disable trophies in their profile settings via the
|
||||
`trophies_enabled` field on `UserProfile`.
|
||||
|
||||
## Database Models
|
||||
|
||||
### Trophy
|
||||
|
||||
Defines an achievement that users can earn.
|
||||
|
||||
**Fields:**
|
||||
- `name`: Trophy name
|
||||
- `description`: How to earn it
|
||||
- `trophy_type`: Type (time, volume, count, sequence, date, other)
|
||||
- `checker_class`: Python class that checks if trophy is earned
|
||||
(e.g., 'count_based')
|
||||
- `checker_params`: JSON parameters for the checker
|
||||
(e.g., `{'count': 10}`)
|
||||
- `is_hidden`: Hidden until earned
|
||||
- `is_progressive`: Shows progress percentage
|
||||
- `is_active`: Can be earned (admins can disable)
|
||||
- `order`: Display order
|
||||
|
||||
### UserTrophy
|
||||
|
||||
Links users to their earned trophies.
|
||||
|
||||
**Fields:**
|
||||
- `user`: User who earned the trophy
|
||||
- `trophy`: The trophy earned
|
||||
- `earned_at`: Timestamp when earned
|
||||
- `progress`: Progress percentage (0-100)
|
||||
- `is_notified`: For future notification system
|
||||
|
||||
### UserStatistics
|
||||
|
||||
Denormalized statistics for efficient trophy checking.
|
||||
|
||||
**Fields:**
|
||||
- `user`: OneToOne with User
|
||||
- `total_weight_lifted`: Cumulative weight in kg
|
||||
- `total_workouts`: Number of workout sessions
|
||||
- `current_streak`: Current consecutive workout days
|
||||
- `longest_streak`: Longest streak ever achieved
|
||||
- `earliest_workout_time`: Earliest workout time ever
|
||||
- `latest_workout_time`: Latest workout time ever
|
||||
- `weekend_workout_streak`: Consecutive weekends with workouts
|
||||
- `last_complete_weekend_date`: Last Saturday with both days worked
|
||||
- `worked_out_jan_1`: Has worked out on any January 1st
|
||||
- `last_inactive_date`: Last workout before 30+ day gap
|
||||
|
||||
## Trophy Checkers
|
||||
|
||||
Trophy checkers are Python classes that determine if a user has earned a trophy.
|
||||
|
||||
### Available Checkers
|
||||
|
||||
1. **count_based**: Check if user reached a count
|
||||
- Params: `{'count': 10}`
|
||||
- Example: "Complete 10 workouts"
|
||||
|
||||
2. **streak**: Check for consecutive day streaks
|
||||
- Params: `{'days': 30}`
|
||||
- Example: "30-day workout streak"
|
||||
|
||||
3. **weekend_warrior**: Check for consecutive complete weekends
|
||||
- Params: `{'weekends': 4}`
|
||||
- Example: "Work out on Saturday AND Sunday for 4 weekends"
|
||||
|
||||
4. **volume**: Check for cumulative weight lifted
|
||||
- Params: `{'kg': 5000}`
|
||||
- Example: "Lift 5,000 kg total"
|
||||
|
||||
5. **time_based**: Check for workout at specific time
|
||||
- Params: `{'before': '06:00'}` or `{'after': '21:00'}`
|
||||
- Example: "Work out before 6:00 AM"
|
||||
|
||||
6. **date_based**: Check for workout on specific date
|
||||
- Params: `{'month': 1, 'day': 1}`
|
||||
- Example: "Work out on January 1st"
|
||||
|
||||
7. **inactivity_return**: Check for return after inactivity
|
||||
- Params: `{'inactive_days': 30}`
|
||||
- Example: "Return to training after 30 days inactive"
|
||||
|
||||
## Management Commands
|
||||
|
||||
### Load Trophies
|
||||
|
||||
Load the initial set of trophies into the database:
|
||||
|
||||
```bash
|
||||
# Load new trophies (skip existing)
|
||||
python manage.py load_trophies
|
||||
|
||||
# Update existing trophies
|
||||
python manage.py load_trophies --update
|
||||
|
||||
# Verbose output
|
||||
python manage.py load_trophies -v 2
|
||||
```
|
||||
|
||||
### Evaluate Trophies
|
||||
|
||||
Manually trigger trophy evaluation:
|
||||
|
||||
```bash
|
||||
# Evaluate for a specific user
|
||||
python manage.py evaluate_trophies --user username
|
||||
|
||||
# Evaluate a specific trophy for all users
|
||||
python manage.py evaluate_trophies --trophy 5
|
||||
|
||||
# Evaluate all trophies for all active users
|
||||
python manage.py evaluate_trophies --all
|
||||
|
||||
# Force re-evaluation (check already earned trophies)
|
||||
python manage.py evaluate_trophies --all --force-reevaluate
|
||||
```
|
||||
|
||||
### Recalculate Statistics
|
||||
|
||||
Rebuild user statistics from workout history:
|
||||
|
||||
```bash
|
||||
# Recalculate for a specific user
|
||||
python manage.py recalculate_statistics --user username
|
||||
|
||||
# Recalculate for all users
|
||||
python manage.py recalculate_statistics --all
|
||||
|
||||
# Recalculate for active users only
|
||||
python manage.py recalculate_statistics --all --active-only
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Trophy Endpoints
|
||||
|
||||
```text
|
||||
GET /api/v2/trophy/
|
||||
List all active trophies
|
||||
- Hidden trophies excluded unless earned by user
|
||||
|
||||
GET /api/v2/trophy/{id}/
|
||||
Get specific trophy details
|
||||
|
||||
GET /api/v2/trophy/progress/
|
||||
Get progress for all trophies (current user)
|
||||
- Returns earned status and progress percentage
|
||||
- Includes current/target values for progressive trophies
|
||||
```
|
||||
|
||||
### User Trophy Endpoints
|
||||
|
||||
```text
|
||||
GET /api/v2/user-trophy/
|
||||
List current user's earned trophies
|
||||
- Ordered by earned_at (newest first)
|
||||
|
||||
GET /api/v2/user-trophy/{id}/
|
||||
Get specific earned trophy details
|
||||
```
|
||||
|
||||
### User Statistics Endpoints
|
||||
|
||||
```text
|
||||
GET /api/v2/user-statistics/
|
||||
Get current user's trophy statistics
|
||||
```
|
||||
|
||||
## API Components
|
||||
|
||||
### Serializers
|
||||
|
||||
**TrophySerializer** (`wger.trophies.api.serializers`):
|
||||
|
||||
- Serializes Trophy model for API responses
|
||||
- Fields: id, uuid, name, description, trophy_type, checker_class,
|
||||
checker_params, is_hidden, is_progressive, is_active, order
|
||||
- Read-only serializer for trophy definitions
|
||||
|
||||
**UserTrophySerializer** (`wger.trophies.api.serializers`):
|
||||
|
||||
- Serializes UserTrophy model (earned trophies)
|
||||
- Fields: id, user, trophy (nested), earned_at, progress, is_notified
|
||||
- Includes nested trophy details in response
|
||||
|
||||
**UserStatisticsSerializer** (`wger.trophies.api.serializers`):
|
||||
|
||||
- Serializes UserStatistics model
|
||||
- Fields: All statistics fields (total_weight_lifted, total_workouts,
|
||||
current_streak, longest_streak, etc.)
|
||||
- Read-only serializer for statistics data
|
||||
|
||||
**TrophyProgressSerializer** (`wger.trophies.api.serializers`):
|
||||
|
||||
- Custom serializer for trophy progress tracking
|
||||
- Fields: trophy (nested), is_earned, earned_at, progress,
|
||||
current_value, target_value
|
||||
- Used by `/api/v2/trophy/progress/` endpoint
|
||||
|
||||
### Filtersets
|
||||
|
||||
**TrophyFilterSet** (`wger.trophies.api.filtersets`):
|
||||
|
||||
- Filter trophies by type, active status, hidden status
|
||||
- Fields: `trophy_type`, `is_active`, `is_hidden`, `is_progressive`
|
||||
- Example: `/api/v2/trophy/?trophy_type=volume&is_active=true`
|
||||
|
||||
**UserTrophyFilterSet** (`wger.trophies.api.filtersets`):
|
||||
|
||||
- Filter user's earned trophies
|
||||
- Fields: `trophy`, `earned_at` (date range), `progress`
|
||||
- Example: `/api/v2/user-trophy/?trophy=5&earned_at__gte=2024-01-01`
|
||||
|
||||
**Example API Usage:**
|
||||
|
||||
```python
|
||||
# Get all volume-based trophies
|
||||
GET /api/v2/trophy/?trophy_type=volume
|
||||
|
||||
# Get trophies earned in 2024
|
||||
GET /api/v2/user-trophy/?earned_at__year=2024
|
||||
|
||||
# Get progress for all trophies (authenticated)
|
||||
GET /api/v2/trophy/progress/
|
||||
|
||||
# Get current user's statistics
|
||||
GET /api/v2/user-statistics/
|
||||
```
|
||||
|
||||
## Adding New Trophies
|
||||
|
||||
### Method 1: Using Code (Recommended for new trophy types)
|
||||
|
||||
1. **Create a new checker class** (if needed) in `wger/trophies/checkers/`:
|
||||
|
||||
```python
|
||||
# wger/trophies/checkers/my_checker.py
|
||||
from .base import BaseTrophyChecker
|
||||
|
||||
class MyCustomChecker(BaseTrophyChecker):
|
||||
def check(self) -> bool:
|
||||
# Your logic here
|
||||
return True
|
||||
|
||||
def get_progress(self) -> float:
|
||||
# Calculate progress 0-100
|
||||
return 50.0
|
||||
|
||||
def get_current_value(self):
|
||||
return "current"
|
||||
|
||||
def get_target_value(self):
|
||||
return "target"
|
||||
```
|
||||
|
||||
2. **Register the checker** in `wger/trophies/checkers/registry.py`:
|
||||
|
||||
```python
|
||||
from .my_checker import MyCustomChecker
|
||||
|
||||
class CheckerRegistry:
|
||||
_registry: Dict[str, Type[BaseTrophyChecker]] = {
|
||||
# ... existing checkers ...
|
||||
'my_custom': MyCustomChecker,
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add the trophy** via Django admin or shell:
|
||||
|
||||
```python
|
||||
from wger.trophies.models import Trophy
|
||||
|
||||
Trophy.objects.create(
|
||||
name="My Custom Trophy",
|
||||
description="Description of how to earn it",
|
||||
trophy_type=Trophy.TYPE_OTHER,
|
||||
checker_class='my_custom',
|
||||
checker_params={'param1': 'value1'},
|
||||
is_hidden=False,
|
||||
is_progressive=True,
|
||||
order=100,
|
||||
)
|
||||
```
|
||||
|
||||
### Method 2: Using Existing Checkers
|
||||
|
||||
Simply create a new Trophy object with an existing checker:
|
||||
|
||||
```python
|
||||
Trophy.objects.create(
|
||||
name="Heavy Lifter",
|
||||
description="Lift 10,000 kg total",
|
||||
trophy_type=Trophy.TYPE_VOLUME,
|
||||
checker_class='volume',
|
||||
checker_params={'kg': 10000},
|
||||
is_progressive=True,
|
||||
order=50,
|
||||
)
|
||||
```
|
||||
|
||||
### Method 3: Using Fixtures
|
||||
|
||||
Create a JSON fixture in `wger/trophies/fixtures/`:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"model": "trophies.trophy",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"name": "My Trophy",
|
||||
"description": "Description",
|
||||
"trophy_type": "count",
|
||||
"checker_class": "count_based",
|
||||
"checker_params": {"count": 100},
|
||||
"is_hidden": false,
|
||||
"is_progressive": true,
|
||||
"is_active": true,
|
||||
"order": 100
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Load with: `python manage.py loaddata my_trophies`
|
||||
|
||||
## Services API
|
||||
|
||||
### UserStatisticsService
|
||||
|
||||
Service for managing user workout statistics.
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `increment_workout(user, workout_date, weight_lifted)`: Incrementally
|
||||
update statistics after a workout. Updates streak, total workouts,
|
||||
weight lifted, and workout times.
|
||||
- `update_statistics(user)`: Recalculate all statistics from scratch
|
||||
by scanning workout history. Use for fixing inconsistencies.
|
||||
- `get_or_create_statistics(user)`: Get existing statistics or create
|
||||
new record with default values.
|
||||
- `handle_workout_deletion(user, workout_date)`: Update statistics
|
||||
after a workout is deleted. Recalculates streaks if needed.
|
||||
|
||||
**Example Usage:**
|
||||
|
||||
```python
|
||||
from wger.trophies.services.statistics import UserStatisticsService
|
||||
from decimal import Decimal
|
||||
import datetime
|
||||
|
||||
# Increment after workout
|
||||
UserStatisticsService.increment_workout(
|
||||
user=request.user,
|
||||
workout_date=datetime.date.today(),
|
||||
weight_lifted=Decimal('150.5')
|
||||
)
|
||||
|
||||
# Recalculate all statistics
|
||||
stats = UserStatisticsService.update_statistics(request.user)
|
||||
|
||||
# Get statistics (create if missing)
|
||||
stats = UserStatisticsService.get_or_create_statistics(request.user)
|
||||
```
|
||||
|
||||
### TrophyService
|
||||
|
||||
Service for evaluating and awarding trophies.
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `evaluate_all_trophies(user, force=False)`: Evaluate all active
|
||||
trophies for a user. Returns list of newly awarded UserTrophy
|
||||
objects. Skip already earned unless force=True.
|
||||
- `award_trophy(user, trophy, progress=100.0)`: Award a specific
|
||||
trophy to a user. Creates UserTrophy record. Idempotent (safe to
|
||||
call multiple times).
|
||||
- `get_user_trophies(user, include_hidden=False)`: Get all trophies
|
||||
earned by a user. Filter hidden trophies unless specified.
|
||||
- `get_all_trophy_progress(user, include_hidden=False)`: Get progress
|
||||
for all trophies. Returns list of dicts with trophy info, earned
|
||||
status, progress %, current/target values.
|
||||
- `reevaluate_trophies(user_ids=None, trophy_id=None,
|
||||
force_reevaluate=False)`: Batch re-evaluate trophies for multiple
|
||||
users. Returns dict with users_checked, trophies_awarded counts.
|
||||
|
||||
**Example Usage:**
|
||||
|
||||
```python
|
||||
from wger.trophies.services.trophy import TrophyService
|
||||
|
||||
# Evaluate all trophies for user
|
||||
newly_awarded = TrophyService.evaluate_all_trophies(request.user)
|
||||
for user_trophy in newly_awarded:
|
||||
print(f"Earned: {user_trophy.trophy.name}")
|
||||
|
||||
# Get user's earned trophies
|
||||
earned = TrophyService.get_user_trophies(request.user)
|
||||
|
||||
# Get progress for all trophies
|
||||
progress = TrophyService.get_all_trophy_progress(request.user)
|
||||
for item in progress:
|
||||
print(f"{item['trophy'].name}: {item['progress']}%")
|
||||
|
||||
# Batch re-evaluate for all active users
|
||||
results = TrophyService.reevaluate_trophies()
|
||||
print(f"Checked {results['users_checked']} users")
|
||||
print(f"Awarded {results['trophies_awarded']} trophies")
|
||||
```
|
||||
|
||||
## Signals and Auto-Evaluation
|
||||
|
||||
### Signal Handlers
|
||||
|
||||
The trophy system uses Django signals for automatic evaluation:
|
||||
|
||||
**workout_log_saved signal** (`wger.manager.signals`):
|
||||
|
||||
- Fires when `WorkoutLog` is saved (user logs exercise sets)
|
||||
- Handler: Updates `UserStatistics` incrementally
|
||||
- Then evaluates all trophies for the user
|
||||
|
||||
**workout_session_saved signal** (`wger.manager.signals`):
|
||||
|
||||
- Fires when `WorkoutSession` is saved (user completes workout)
|
||||
- Handler: Updates workout count and streak
|
||||
- Then evaluates all trophies for the user
|
||||
|
||||
**workout_deleted signal** (`wger.manager.signals`):
|
||||
|
||||
- Fires when workout is deleted
|
||||
- Handler: Calls `handle_workout_deletion()` to update statistics
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Signals are automatically connected in `wger/trophies/apps.py`:
|
||||
|
||||
```python
|
||||
class TrophiesConfig(AppConfig):
|
||||
def ready(self):
|
||||
import wger.trophies.signals # noqa: F401
|
||||
```
|
||||
|
||||
### How Auto-Evaluation Works
|
||||
|
||||
When a user logs a workout:
|
||||
|
||||
1. `WorkoutSession` or `WorkoutLog` is saved
|
||||
2. Django signal fires (`post_save`)
|
||||
3. `UserStatisticsService.increment_workout()` updates statistics
|
||||
incrementally
|
||||
4. `TrophyService.evaluate_all_trophies()` checks for newly earned
|
||||
trophies
|
||||
5. Earned trophies create `UserTrophy` records
|
||||
6. User sees new trophy (notifications in future version)
|
||||
|
||||
### Celery Tasks (Optional)
|
||||
|
||||
For async evaluation:
|
||||
|
||||
```python
|
||||
from wger.trophies.tasks import evaluate_user_trophies_task
|
||||
|
||||
# Evaluate asynchronously
|
||||
evaluate_user_trophies_task.delay(user_id)
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- **Denormalized Statistics**: `UserStatistics` table provides O(1)
|
||||
lookups
|
||||
- **Incremental Updates**: Statistics update incrementally, not full
|
||||
recalculation
|
||||
- **Inactive User Skipping**: Users inactive >30 days are skipped
|
||||
- **Bulk Operations**: Batch evaluation supports chunking for large
|
||||
user sets
|
||||
|
||||
## Testing
|
||||
|
||||
Run the trophy test suite:
|
||||
|
||||
```bash
|
||||
# All trophy tests
|
||||
python manage.py test wger.trophies.tests
|
||||
|
||||
# Specific test files
|
||||
python manage.py test wger.trophies.tests.test_models
|
||||
python manage.py test wger.trophies.tests.test_checkers
|
||||
python manage.py test wger.trophies.tests.test_services
|
||||
python manage.py test wger.trophies.tests.test_api
|
||||
python manage.py test wger.trophies.tests.test_integration
|
||||
```
|
||||
|
||||
## Initial Trophies
|
||||
|
||||
The system includes 9 initial trophies:
|
||||
|
||||
1. **Beginner**: Complete your first workout
|
||||
2. **Unstoppable**: Maintain a 30-day workout streak
|
||||
3. **Weekend Warrior**: Work out on Saturday and Sunday for 4 consecutive
|
||||
weekends
|
||||
4. **Lifter**: Lift a cumulative total of 5,000 kg
|
||||
5. **Atlas**: Lift a cumulative total of 100,000 kg
|
||||
6. **Early Bird**: Complete a workout before 6:00 AM
|
||||
7. **Night Owl**: Complete a workout after 9:00 PM
|
||||
8. **New Year, New Me**: Work out on January 1st
|
||||
9. **Phoenix** (Hidden): Return to training after being inactive for
|
||||
30 days
|
||||
|
||||
Load them with: `python manage.py load_trophies`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Trophies not being awarded
|
||||
|
||||
1. Check if trophy system is enabled: `TROPHIES_ENABLED = True`
|
||||
2. Check user's profile: `user.userprofile.trophies_enabled`
|
||||
3. Check user activity: Not inactive >30 days
|
||||
4. Check trophy is active: `trophy.is_active = True`
|
||||
5. Recalculate statistics:
|
||||
`python manage.py recalculate_statistics --user username`
|
||||
|
||||
### Statistics not updating
|
||||
|
||||
1. Check signals are connected (should happen automatically)
|
||||
2. Manually recalculate: `python manage.py recalculate_statistics --all`
|
||||
3. Check for errors in logs
|
||||
|
||||
### Performance issues
|
||||
|
||||
1. Add database indexes (already included in migrations)
|
||||
2. Enable Celery for async evaluation
|
||||
3. Adjust `TROPHIES_INACTIVE_USER_DAYS` to skip more users
|
||||
4. Disable trophies for inactive users in bulk
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 (same as wger Workout Manager)
|
||||
|
||||
15
wger/trophies/api/__init__.py
Normal file
15
wger/trophies/api/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
55
wger/trophies/api/filtersets.py
Normal file
55
wger/trophies/api/filtersets.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Third Party
|
||||
from django_filters import rest_framework as filters
|
||||
|
||||
# wger
|
||||
from wger.trophies.models import (
|
||||
Trophy,
|
||||
UserTrophy,
|
||||
)
|
||||
|
||||
|
||||
class TrophyFilterSet(filters.FilterSet):
|
||||
"""
|
||||
Filter set for Trophy model.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Trophy
|
||||
fields = {
|
||||
'id': ['exact', 'in'],
|
||||
'trophy_type': ['exact', 'in'],
|
||||
'is_active': ['exact'],
|
||||
'is_hidden': ['exact'],
|
||||
'is_progressive': ['exact'],
|
||||
}
|
||||
|
||||
|
||||
class UserTrophyFilterSet(filters.FilterSet):
|
||||
"""
|
||||
Filter set for UserTrophy model.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = UserTrophy
|
||||
fields = {
|
||||
'id': ['exact', 'in'],
|
||||
'trophy': ['exact', 'in'],
|
||||
'earned_at': ['exact', 'gt', 'gte', 'lt', 'lte'],
|
||||
'is_notified': ['exact'],
|
||||
}
|
||||
112
wger/trophies/api/serializers.py
Normal file
112
wger/trophies/api/serializers.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Third Party
|
||||
from rest_framework import serializers
|
||||
|
||||
# wger
|
||||
from wger.trophies.models import (
|
||||
Trophy,
|
||||
UserStatistics,
|
||||
UserTrophy,
|
||||
)
|
||||
|
||||
|
||||
class TrophySerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for Trophy model.
|
||||
|
||||
Shows trophy information for listing active trophies.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Trophy
|
||||
fields = (
|
||||
'id',
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'image',
|
||||
'trophy_type',
|
||||
'is_hidden',
|
||||
'is_progressive',
|
||||
'order',
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class UserTrophySerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for UserTrophy model.
|
||||
|
||||
Shows user's earned trophies with trophy details.
|
||||
"""
|
||||
|
||||
trophy = TrophySerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = UserTrophy
|
||||
fields = (
|
||||
'id',
|
||||
'trophy',
|
||||
'earned_at',
|
||||
'progress',
|
||||
'is_notified',
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class UserStatisticsSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for UserStatistics model.
|
||||
|
||||
Shows user's trophy-related statistics.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = UserStatistics
|
||||
fields = (
|
||||
'id',
|
||||
'total_weight_lifted',
|
||||
'total_workouts',
|
||||
'current_streak',
|
||||
'longest_streak',
|
||||
'last_workout_date',
|
||||
'earliest_workout_time',
|
||||
'latest_workout_time',
|
||||
'weekend_workout_streak',
|
||||
'last_complete_weekend_date',
|
||||
'worked_out_jan_1',
|
||||
'last_updated',
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class TrophyProgressSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for trophy progress information.
|
||||
|
||||
Used for showing progress on all trophies (earned and unearned).
|
||||
This is not a ModelSerializer as it aggregates data from multiple sources.
|
||||
"""
|
||||
|
||||
trophy = TrophySerializer(read_only=True)
|
||||
is_earned = serializers.BooleanField()
|
||||
earned_at = serializers.DateTimeField(allow_null=True)
|
||||
progress = serializers.FloatField()
|
||||
current_value = serializers.CharField(allow_null=True)
|
||||
target_value = serializers.CharField(allow_null=True)
|
||||
progress_display = serializers.CharField(allow_null=True)
|
||||
190
wger/trophies/api/views.py
Normal file
190
wger/trophies/api/views.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Third Party
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
)
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
# wger
|
||||
from wger.trophies.api.filtersets import (
|
||||
TrophyFilterSet,
|
||||
UserTrophyFilterSet,
|
||||
)
|
||||
from wger.trophies.api.serializers import (
|
||||
TrophyProgressSerializer,
|
||||
TrophySerializer,
|
||||
UserStatisticsSerializer,
|
||||
UserTrophySerializer,
|
||||
)
|
||||
from wger.trophies.models import (
|
||||
Trophy,
|
||||
UserStatistics,
|
||||
UserTrophy,
|
||||
)
|
||||
from wger.trophies.services import TrophyService
|
||||
|
||||
|
||||
class TrophyViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
API endpoint for Trophy objects.
|
||||
|
||||
Returns active trophies. Hidden trophies are excluded unless:
|
||||
- The user has earned them, or
|
||||
- The user is staff
|
||||
|
||||
list:
|
||||
Return a list of active trophies
|
||||
|
||||
retrieve:
|
||||
Return a specific trophy by ID
|
||||
"""
|
||||
|
||||
serializer_class = TrophySerializer
|
||||
filterset_class = TrophyFilterSet
|
||||
ordering_fields = ['order', 'name', 'trophy_type']
|
||||
ordering = ['order', 'name']
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return active trophies, filtering hidden ones appropriately.
|
||||
"""
|
||||
# REST API generation
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
return Trophy.objects.none()
|
||||
|
||||
user = self.request.user
|
||||
queryset = Trophy.objects.filter(is_active=True)
|
||||
|
||||
# Staff can see all trophies
|
||||
if user.is_staff:
|
||||
return queryset
|
||||
|
||||
# For regular users, exclude hidden trophies unless earned
|
||||
if user.is_authenticated:
|
||||
earned_trophy_ids = UserTrophy.objects.filter(user=user).values_list(
|
||||
'trophy_id', flat=True
|
||||
)
|
||||
return queryset.filter(is_hidden=False) | queryset.filter(id__in=earned_trophy_ids)
|
||||
|
||||
# Anonymous users only see non-hidden trophies
|
||||
return queryset.filter(is_hidden=False)
|
||||
|
||||
@extend_schema(
|
||||
summary="Get trophy progress",
|
||||
description="""
|
||||
Return all trophies with progress information for the current user.
|
||||
|
||||
For each trophy, returns:
|
||||
- Trophy information (id, name, description, type, etc.)
|
||||
- Whether the trophy has been earned
|
||||
- Earned timestamp (if earned)
|
||||
- Progress percentage (0-100)
|
||||
- Current and target values (for progressive trophies)
|
||||
|
||||
Hidden trophies are excluded unless earned (or user is staff).
|
||||
""",
|
||||
responses={
|
||||
200: TrophyProgressSerializer(many=True),
|
||||
},
|
||||
)
|
||||
@action(detail=False, methods=['get'])
|
||||
def progress(self, request):
|
||||
"""
|
||||
Return all trophies with progress information for the current user.
|
||||
|
||||
For each trophy, returns:
|
||||
- Trophy info
|
||||
- Whether earned
|
||||
- Earned timestamp (if earned)
|
||||
- Progress percentage (0-100)
|
||||
- Current/target values (for progressive trophies)
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return Response([])
|
||||
|
||||
include_hidden = request.user.is_staff
|
||||
progress_data = TrophyService.get_all_trophy_progress(
|
||||
request.user,
|
||||
include_hidden=include_hidden,
|
||||
)
|
||||
|
||||
serializer = TrophyProgressSerializer(progress_data, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class UserTrophyViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
API endpoint for user's earned trophies.
|
||||
|
||||
Returns the current user's earned trophies.
|
||||
|
||||
list:
|
||||
Return all earned trophies for the current user
|
||||
|
||||
retrieve:
|
||||
Return a specific user trophy by ID
|
||||
"""
|
||||
|
||||
serializer_class = UserTrophySerializer
|
||||
filterset_class = UserTrophyFilterSet
|
||||
ordering_fields = ['earned_at', 'trophy__name']
|
||||
ordering = ['-earned_at']
|
||||
|
||||
is_private = True
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return only the current user's trophies.
|
||||
"""
|
||||
# REST API generation
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
return UserTrophy.objects.none()
|
||||
|
||||
return UserTrophy.objects.filter(user=self.request.user).select_related('trophy')
|
||||
|
||||
|
||||
class UserStatisticsViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
API endpoint for user's trophy statistics.
|
||||
|
||||
Returns the current user's trophy-related statistics.
|
||||
|
||||
list:
|
||||
Return the current user's statistics
|
||||
|
||||
retrieve:
|
||||
Return statistics by ID
|
||||
"""
|
||||
|
||||
serializer_class = UserStatisticsSerializer
|
||||
ordering_fields = '__all__'
|
||||
|
||||
is_private = True
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return only the current user's statistics.
|
||||
"""
|
||||
# REST API generation
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
return UserStatistics.objects.none()
|
||||
|
||||
return UserStatistics.objects.filter(user=self.request.user)
|
||||
26
wger/trophies/apps.py
Normal file
26
wger/trophies/apps.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Django
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TrophiesConfig(AppConfig):
|
||||
name = 'wger.trophies'
|
||||
verbose_name = 'Trophies'
|
||||
|
||||
def ready(self):
|
||||
import wger.trophies.signals # noqa: F401
|
||||
26
wger/trophies/checkers/__init__.py
Normal file
26
wger/trophies/checkers/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Local
|
||||
from .base import BaseTrophyChecker
|
||||
from .count_based import CountBasedChecker
|
||||
from .date_based import DateBasedChecker
|
||||
from .inactivity_return import InactivityReturnChecker
|
||||
from .registry import CheckerRegistry
|
||||
from .streak import StreakChecker
|
||||
from .time_based import TimeBasedChecker
|
||||
from .volume import VolumeChecker
|
||||
from .weekend_warrior import WeekendWarriorChecker
|
||||
126
wger/trophies/checkers/base.py
Normal file
126
wger/trophies/checkers/base.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
from abc import (
|
||||
ABC,
|
||||
abstractmethod,
|
||||
)
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
)
|
||||
|
||||
# Django
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
class BaseTrophyChecker(ABC):
|
||||
"""
|
||||
Abstract base class for all trophy checkers.
|
||||
|
||||
Each trophy type has a corresponding checker class that knows how to
|
||||
evaluate whether a user has earned that trophy.
|
||||
"""
|
||||
|
||||
def __init__(self, user: User, trophy: 'Trophy', params: dict):
|
||||
"""
|
||||
Initialize the checker.
|
||||
|
||||
Args:
|
||||
user: The user to check the trophy for
|
||||
trophy: The trophy being checked
|
||||
params: Parameters from the trophy's checker_params field
|
||||
"""
|
||||
self.user = user
|
||||
self.trophy = trophy
|
||||
self.params = params
|
||||
self._statistics = None
|
||||
|
||||
@property
|
||||
def statistics(self):
|
||||
"""
|
||||
Lazy-load the user's statistics.
|
||||
"""
|
||||
if self._statistics is None:
|
||||
from wger.trophies.models import UserStatistics
|
||||
self._statistics, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
return self._statistics
|
||||
|
||||
@abstractmethod
|
||||
def check(self) -> bool:
|
||||
"""
|
||||
Check if the user has earned the trophy.
|
||||
|
||||
Returns:
|
||||
True if the trophy has been earned, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_progress(self) -> float:
|
||||
"""
|
||||
Get the user's progress towards earning the trophy.
|
||||
|
||||
Returns:
|
||||
A float between 0 and 100 representing percentage progress
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_target_value(self) -> Any:
|
||||
"""
|
||||
Get the target value the user needs to achieve.
|
||||
|
||||
Returns:
|
||||
The target value (type depends on trophy type)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_current_value(self) -> Any:
|
||||
"""
|
||||
Get the user's current value towards the target.
|
||||
|
||||
Returns:
|
||||
The current value (type depends on trophy type)
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_progress_display(self) -> str:
|
||||
"""
|
||||
Get a human-readable string describing the progress.
|
||||
|
||||
Returns:
|
||||
A formatted string showing current/target progress
|
||||
"""
|
||||
current = self.get_current_value()
|
||||
target = self.get_target_value()
|
||||
return f'{current} / {target}'
|
||||
|
||||
def validate_params(self) -> bool:
|
||||
"""
|
||||
Validate that the required parameters are present.
|
||||
|
||||
Override this method in subclasses to add specific validation.
|
||||
|
||||
Returns:
|
||||
True if parameters are valid, False otherwise
|
||||
"""
|
||||
return True
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<{self.__class__.__name__}(user={self.user.username}, trophy={self.trophy.name})>'
|
||||
72
wger/trophies/checkers/count_based.py
Normal file
72
wger/trophies/checkers/count_based.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
from typing import Any
|
||||
|
||||
# Local
|
||||
from .base import BaseTrophyChecker
|
||||
|
||||
|
||||
class CountBasedChecker(BaseTrophyChecker):
|
||||
"""
|
||||
Checker for count-based trophies.
|
||||
|
||||
Used for trophies that require completing a certain number of workouts.
|
||||
|
||||
Expected params:
|
||||
count (int): The number of workouts required to earn the trophy
|
||||
|
||||
Example:
|
||||
Beginner trophy: params={'count': 1}
|
||||
Dedicated trophy: params={'count': 100}
|
||||
"""
|
||||
|
||||
def validate_params(self) -> bool:
|
||||
"""Validate that count parameter is present and valid."""
|
||||
count = self.params.get('count')
|
||||
return count is not None and isinstance(count, int) and count > 0
|
||||
|
||||
def check(self) -> bool:
|
||||
"""Check if user has completed required number of workouts."""
|
||||
if not self.validate_params():
|
||||
return False
|
||||
return self.get_current_value() >= self.get_target_value()
|
||||
|
||||
def get_progress(self) -> float:
|
||||
"""Get progress as percentage of workouts completed."""
|
||||
if not self.validate_params():
|
||||
return 0.0
|
||||
target = self.get_target_value()
|
||||
if target <= 0:
|
||||
return 0.0
|
||||
current = self.get_current_value()
|
||||
progress = (current / target) * 100
|
||||
return min(progress, 100.0)
|
||||
|
||||
def get_target_value(self) -> int:
|
||||
"""Get the target number of workouts."""
|
||||
return self.params.get('count', 0)
|
||||
|
||||
def get_current_value(self) -> int:
|
||||
"""Get the user's current workout count."""
|
||||
return self.statistics.total_workouts
|
||||
|
||||
def get_progress_display(self) -> str:
|
||||
"""Get human-readable progress string."""
|
||||
current = self.get_current_value()
|
||||
target = self.get_target_value()
|
||||
return f'{current} / {target} workouts'
|
||||
109
wger/trophies/checkers/date_based.py
Normal file
109
wger/trophies/checkers/date_based.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
from typing import Any
|
||||
|
||||
# Local
|
||||
from .base import BaseTrophyChecker
|
||||
|
||||
|
||||
class DateBasedChecker(BaseTrophyChecker):
|
||||
"""
|
||||
Checker for date-based trophies.
|
||||
|
||||
Used for trophies that require working out on a specific date (month/day).
|
||||
|
||||
Expected params:
|
||||
month (int): The month (1-12) when the workout must occur
|
||||
day (int): The day of the month (1-31) when the workout must occur
|
||||
|
||||
Example:
|
||||
New Year, New Me trophy: params={'month': 1, 'day': 1}
|
||||
"""
|
||||
|
||||
def validate_params(self) -> bool:
|
||||
"""Validate that month and day parameters are present and valid."""
|
||||
month = self.params.get('month')
|
||||
day = self.params.get('day')
|
||||
|
||||
if month is None or day is None:
|
||||
return False
|
||||
|
||||
if not isinstance(month, int) or not isinstance(day, int):
|
||||
return False
|
||||
|
||||
if month < 1 or month > 12:
|
||||
return False
|
||||
|
||||
if day < 1 or day > 31:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def check(self) -> bool:
|
||||
"""Check if user has worked out on the specified date."""
|
||||
if not self.validate_params():
|
||||
return False
|
||||
|
||||
month = self.params.get('month')
|
||||
day = self.params.get('day')
|
||||
|
||||
# Special case for January 1st - we store this flag directly
|
||||
if month == 1 and day == 1:
|
||||
return self.statistics.worked_out_jan_1
|
||||
|
||||
# For other dates, we need to query the workout sessions
|
||||
# This is done in the statistics service when updating
|
||||
from wger.manager.models import WorkoutSession
|
||||
return WorkoutSession.objects.filter(
|
||||
user=self.user,
|
||||
date__month=month,
|
||||
date__day=day,
|
||||
).exists()
|
||||
|
||||
def get_progress(self) -> float:
|
||||
"""
|
||||
Get progress towards the trophy.
|
||||
|
||||
For date-based trophies, progress is binary - either achieved (100%) or not (0%).
|
||||
"""
|
||||
return 100.0 if self.check() else 0.0
|
||||
|
||||
def get_target_value(self) -> str:
|
||||
"""Get the target date as a string."""
|
||||
month = self.params.get('month')
|
||||
day = self.params.get('day')
|
||||
|
||||
if month is None or day is None:
|
||||
return 'N/A'
|
||||
|
||||
# Convert to month name
|
||||
import calendar
|
||||
month_name = calendar.month_name[month]
|
||||
return f'{month_name} {day}'
|
||||
|
||||
def get_current_value(self) -> str:
|
||||
"""Get whether the user has achieved this."""
|
||||
if self.check():
|
||||
return 'Achieved'
|
||||
return 'Not yet'
|
||||
|
||||
def get_progress_display(self) -> str:
|
||||
"""Get human-readable progress string."""
|
||||
if self.check():
|
||||
return 'Achieved!'
|
||||
return f'Work out on {self.get_target_value()}'
|
||||
118
wger/trophies/checkers/inactivity_return.py
Normal file
118
wger/trophies/checkers/inactivity_return.py
Normal file
@@ -0,0 +1,118 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
# Local
|
||||
from .base import BaseTrophyChecker
|
||||
|
||||
|
||||
class InactivityReturnChecker(BaseTrophyChecker):
|
||||
"""
|
||||
Checker for inactivity return trophies (Phoenix trophy).
|
||||
|
||||
Used for trophies that reward users for returning to training after
|
||||
being inactive for a certain period.
|
||||
|
||||
Expected params:
|
||||
inactive_days (int): The minimum number of inactive days before returning
|
||||
|
||||
Example:
|
||||
Phoenix trophy: params={'inactive_days': 30}
|
||||
"""
|
||||
|
||||
def validate_params(self) -> bool:
|
||||
"""Validate that inactive_days parameter is present and valid."""
|
||||
inactive_days = self.params.get('inactive_days')
|
||||
return inactive_days is not None and isinstance(inactive_days, int) and inactive_days > 0
|
||||
|
||||
def check(self) -> bool:
|
||||
"""
|
||||
Check if user has returned to training after being inactive.
|
||||
|
||||
The user earns this trophy if:
|
||||
1. They have a last_inactive_date recorded (meaning they were inactive)
|
||||
2. They have a last_workout_date after the inactive period
|
||||
3. The gap between last_inactive_date and the workout before that was >= inactive_days
|
||||
"""
|
||||
if not self.validate_params():
|
||||
return False
|
||||
|
||||
last_inactive_date = self.statistics.last_inactive_date
|
||||
last_workout_date = self.statistics.last_workout_date
|
||||
|
||||
# If no inactive date recorded, user hasn't had a qualifying gap yet
|
||||
if last_inactive_date is None:
|
||||
return False
|
||||
|
||||
# If no workout after the inactive period, haven't returned yet
|
||||
if last_workout_date is None:
|
||||
return False
|
||||
|
||||
# Check if they've worked out after the inactive date
|
||||
# The statistics service sets last_inactive_date when it detects
|
||||
# a gap of >= inactive_days before a new workout
|
||||
return last_workout_date > last_inactive_date
|
||||
|
||||
def get_progress(self) -> float:
|
||||
"""
|
||||
Get progress towards the trophy.
|
||||
|
||||
This is a special trophy - progress shows how close to earning it after returning.
|
||||
If they haven't been inactive long enough, shows 0.
|
||||
If they've returned after inactivity, shows 100.
|
||||
"""
|
||||
return 100.0 if self.check() else 0.0
|
||||
|
||||
def get_target_value(self) -> int:
|
||||
"""Get the required number of inactive days."""
|
||||
return self.params.get('inactive_days', 0)
|
||||
|
||||
def get_current_value(self) -> str:
|
||||
"""Get the current status."""
|
||||
if self.check():
|
||||
return 'Returned after inactivity'
|
||||
|
||||
last_workout = self.statistics.last_workout_date
|
||||
if last_workout is None:
|
||||
return 'No workouts yet'
|
||||
|
||||
# Calculate days since last workout
|
||||
today = datetime.date.today()
|
||||
days_inactive = (today - last_workout).days
|
||||
|
||||
return f'{days_inactive} days since last workout'
|
||||
|
||||
def get_progress_display(self) -> str:
|
||||
"""Get human-readable progress string."""
|
||||
if self.check():
|
||||
return 'Achieved! Welcome back!'
|
||||
|
||||
target_days = self.get_target_value()
|
||||
last_workout = self.statistics.last_workout_date
|
||||
|
||||
if last_workout is None:
|
||||
return f'Complete a workout, then return after {target_days}+ days of rest'
|
||||
|
||||
today = datetime.date.today()
|
||||
days_inactive = (today - last_workout).days
|
||||
|
||||
if days_inactive >= target_days:
|
||||
return 'Return to training to earn this trophy!'
|
||||
|
||||
return f'{days_inactive} / {target_days} days inactive'
|
||||
159
wger/trophies/checkers/registry.py
Normal file
159
wger/trophies/checkers/registry.py
Normal file
@@ -0,0 +1,159 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
import logging
|
||||
from typing import (
|
||||
Dict,
|
||||
Optional,
|
||||
Type,
|
||||
)
|
||||
|
||||
# Django
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
# Local
|
||||
from .base import BaseTrophyChecker
|
||||
from .count_based import CountBasedChecker
|
||||
from .date_based import DateBasedChecker
|
||||
from .inactivity_return import InactivityReturnChecker
|
||||
from .streak import StreakChecker
|
||||
from .time_based import TimeBasedChecker
|
||||
from .volume import VolumeChecker
|
||||
from .weekend_warrior import WeekendWarriorChecker
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CheckerRegistry:
|
||||
"""
|
||||
Registry for trophy checker classes.
|
||||
|
||||
Maps checker class names (as stored in Trophy.checker_class) to their
|
||||
actual Python classes.
|
||||
"""
|
||||
|
||||
# Registry mapping simple keys to checker classes
|
||||
# Using simple keys instead of full Python paths to avoid breakage if module structure changes
|
||||
_registry: Dict[str, Type[BaseTrophyChecker]] = {
|
||||
'count_based': CountBasedChecker,
|
||||
'streak': StreakChecker,
|
||||
'weekend_warrior': WeekendWarriorChecker,
|
||||
'volume': VolumeChecker,
|
||||
'time_based': TimeBasedChecker,
|
||||
'date_based': DateBasedChecker,
|
||||
'inactivity_return': InactivityReturnChecker,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def register(cls, class_path: str, checker_class: Type[BaseTrophyChecker]) -> None:
|
||||
"""
|
||||
Register a new checker class.
|
||||
|
||||
Args:
|
||||
class_path: The path string to use in Trophy.checker_class
|
||||
checker_class: The checker class to register
|
||||
"""
|
||||
if not issubclass(checker_class, BaseTrophyChecker):
|
||||
raise ValueError(f'{checker_class} must be a subclass of BaseTrophyChecker')
|
||||
cls._registry[class_path] = checker_class
|
||||
|
||||
@classmethod
|
||||
def unregister(cls, class_path: str) -> None:
|
||||
"""
|
||||
Unregister a checker class.
|
||||
|
||||
Args:
|
||||
class_path: The path string to remove
|
||||
"""
|
||||
cls._registry.pop(class_path, None)
|
||||
|
||||
@classmethod
|
||||
def get_checker_class(cls, class_path: str) -> Optional[Type[BaseTrophyChecker]]:
|
||||
"""
|
||||
Get a checker class by its path.
|
||||
|
||||
Args:
|
||||
class_path: The path string from Trophy.checker_class
|
||||
|
||||
Returns:
|
||||
The checker class, or None if not found
|
||||
"""
|
||||
return cls._registry.get(class_path)
|
||||
|
||||
@classmethod
|
||||
def get_all_checkers(cls) -> Dict[str, Type[BaseTrophyChecker]]:
|
||||
"""
|
||||
Get all registered checker classes.
|
||||
|
||||
Returns:
|
||||
A copy of the registry dictionary
|
||||
"""
|
||||
return cls._registry.copy()
|
||||
|
||||
@classmethod
|
||||
def create_checker(
|
||||
cls,
|
||||
user: User,
|
||||
trophy: 'Trophy',
|
||||
) -> Optional[BaseTrophyChecker]:
|
||||
"""
|
||||
Factory method to create a checker instance for a trophy.
|
||||
|
||||
Args:
|
||||
user: The user to check the trophy for
|
||||
trophy: The trophy to check
|
||||
|
||||
Returns:
|
||||
An instance of the appropriate checker class, or None if the
|
||||
checker class is not found in the registry
|
||||
"""
|
||||
checker_class = cls.get_checker_class(trophy.checker_class)
|
||||
|
||||
if checker_class is None:
|
||||
logger.warning(
|
||||
f'Checker class not found in registry: {trophy.checker_class} '
|
||||
f'for trophy: {trophy.name}'
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return checker_class(
|
||||
user=user,
|
||||
trophy=trophy,
|
||||
params=trophy.checker_params or {},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error creating checker for trophy {trophy.name}: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_checker_for_trophy(user: User, trophy: 'Trophy') -> Optional[BaseTrophyChecker]:
|
||||
"""
|
||||
Convenience function to get a checker instance for a trophy.
|
||||
|
||||
Args:
|
||||
user: The user to check the trophy for
|
||||
trophy: The trophy to check
|
||||
|
||||
Returns:
|
||||
An instance of the appropriate checker class, or None if not found
|
||||
"""
|
||||
return CheckerRegistry.create_checker(user, trophy)
|
||||
77
wger/trophies/checkers/streak.py
Normal file
77
wger/trophies/checkers/streak.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
from typing import Any
|
||||
|
||||
# Local
|
||||
from .base import BaseTrophyChecker
|
||||
|
||||
|
||||
class StreakChecker(BaseTrophyChecker):
|
||||
"""
|
||||
Checker for streak-based trophies.
|
||||
|
||||
Used for trophies that require working out for consecutive days.
|
||||
|
||||
Expected params:
|
||||
days (int): The number of consecutive days required
|
||||
|
||||
Example:
|
||||
Unstoppable trophy: params={'days': 30}
|
||||
"""
|
||||
|
||||
def validate_params(self) -> bool:
|
||||
"""Validate that days parameter is present and valid."""
|
||||
days = self.params.get('days')
|
||||
return days is not None and isinstance(days, int) and days > 0
|
||||
|
||||
def check(self) -> bool:
|
||||
"""Check if user has achieved the required streak."""
|
||||
if not self.validate_params():
|
||||
return False
|
||||
# Check both current streak and longest streak (in case they achieved it before)
|
||||
target = self.get_target_value()
|
||||
return (
|
||||
self.statistics.current_streak >= target
|
||||
or self.statistics.longest_streak >= target
|
||||
)
|
||||
|
||||
def get_progress(self) -> float:
|
||||
"""Get progress as percentage of streak achieved."""
|
||||
if not self.validate_params():
|
||||
return 0.0
|
||||
target = self.get_target_value()
|
||||
if target <= 0:
|
||||
return 0.0
|
||||
# Use the maximum of current or longest streak for progress
|
||||
current = self.get_current_value()
|
||||
progress = (current / target) * 100
|
||||
return min(progress, 100.0)
|
||||
|
||||
def get_target_value(self) -> int:
|
||||
"""Get the target streak length in days."""
|
||||
return self.params.get('days', 0)
|
||||
|
||||
def get_current_value(self) -> int:
|
||||
"""Get the user's best streak (max of current and longest)."""
|
||||
return max(self.statistics.current_streak, self.statistics.longest_streak)
|
||||
|
||||
def get_progress_display(self) -> str:
|
||||
"""Get human-readable progress string."""
|
||||
current = self.get_current_value()
|
||||
target = self.get_target_value()
|
||||
return f'{current} / {target} days'
|
||||
149
wger/trophies/checkers/time_based.py
Normal file
149
wger/trophies/checkers/time_based.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
import datetime
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
)
|
||||
|
||||
# Local
|
||||
from .base import BaseTrophyChecker
|
||||
|
||||
|
||||
class TimeBasedChecker(BaseTrophyChecker):
|
||||
"""
|
||||
Checker for time-based trophies.
|
||||
|
||||
Used for trophies that require working out before or after a certain time.
|
||||
|
||||
Expected params:
|
||||
before (str, optional): Time string in HH:MM format - workout must be before this time
|
||||
after (str, optional): Time string in HH:MM format - workout must be after this time
|
||||
|
||||
At least one of 'before' or 'after' must be provided.
|
||||
|
||||
Example:
|
||||
Early Bird trophy: params={'before': '06:00'}
|
||||
Night Owl trophy: params={'after': '21:00'}
|
||||
"""
|
||||
|
||||
def _parse_time(self, time_str: str) -> Optional[datetime.time]:
|
||||
"""Parse a time string in HH:MM format."""
|
||||
try:
|
||||
parts = time_str.split(':')
|
||||
hour = int(parts[0])
|
||||
minute = int(parts[1]) if len(parts) > 1 else 0
|
||||
return datetime.time(hour=hour, minute=minute)
|
||||
except (ValueError, IndexError, AttributeError):
|
||||
return None
|
||||
|
||||
def validate_params(self) -> bool:
|
||||
"""Validate that at least one of before/after is present and valid."""
|
||||
before = self.params.get('before')
|
||||
after = self.params.get('after')
|
||||
|
||||
if before is None and after is None:
|
||||
return False
|
||||
|
||||
if before is not None and self._parse_time(before) is None:
|
||||
return False
|
||||
|
||||
if after is not None and self._parse_time(after) is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def check(self) -> bool:
|
||||
"""Check if user has worked out at the required time."""
|
||||
if not self.validate_params():
|
||||
return False
|
||||
|
||||
before = self.params.get('before')
|
||||
after = self.params.get('after')
|
||||
|
||||
if before is not None:
|
||||
# Check if user has ever worked out before the specified time
|
||||
target_time = self._parse_time(before)
|
||||
earliest = self.statistics.earliest_workout_time
|
||||
if earliest is not None and earliest < target_time:
|
||||
return True
|
||||
|
||||
if after is not None:
|
||||
# Check if user has ever worked out after the specified time
|
||||
target_time = self._parse_time(after)
|
||||
latest = self.statistics.latest_workout_time
|
||||
if latest is not None and latest > target_time:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_progress(self) -> float:
|
||||
"""
|
||||
Get progress towards the trophy.
|
||||
|
||||
For time-based trophies, progress is binary - either achieved (100%) or not (0%).
|
||||
"""
|
||||
return 100.0 if self.check() else 0.0
|
||||
|
||||
def get_target_value(self) -> str:
|
||||
"""Get the target time as a string."""
|
||||
before = self.params.get('before')
|
||||
after = self.params.get('after')
|
||||
|
||||
if before is not None:
|
||||
return f'Before {before}'
|
||||
if after is not None:
|
||||
return f'After {after}'
|
||||
return 'N/A'
|
||||
|
||||
def get_current_value(self) -> str:
|
||||
"""Get the user's relevant workout time."""
|
||||
before = self.params.get('before')
|
||||
|
||||
if before is not None:
|
||||
earliest = self.statistics.earliest_workout_time
|
||||
if earliest is not None:
|
||||
return earliest.strftime('%H:%M')
|
||||
return 'No workouts yet'
|
||||
|
||||
# For 'after' condition
|
||||
latest = self.statistics.latest_workout_time
|
||||
if latest is not None:
|
||||
return latest.strftime('%H:%M')
|
||||
return 'No workouts yet'
|
||||
|
||||
def get_progress_display(self) -> str:
|
||||
"""Get human-readable progress string."""
|
||||
if self.check():
|
||||
return 'Achieved!'
|
||||
|
||||
before = self.params.get('before')
|
||||
if before is not None:
|
||||
earliest = self.statistics.earliest_workout_time
|
||||
if earliest is not None:
|
||||
return f'Earliest: {earliest.strftime("%H:%M")} (need before {before})'
|
||||
return f'Work out before {before}'
|
||||
|
||||
after = self.params.get('after')
|
||||
if after is not None:
|
||||
latest = self.statistics.latest_workout_time
|
||||
if latest is not None:
|
||||
return f'Latest: {latest.strftime("%H:%M")} (need after {after})'
|
||||
return f'Work out after {after}'
|
||||
|
||||
return 'N/A'
|
||||
88
wger/trophies/checkers/volume.py
Normal file
88
wger/trophies/checkers/volume.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
from decimal import Decimal
|
||||
from typing import (
|
||||
Any,
|
||||
Union,
|
||||
)
|
||||
|
||||
# Local
|
||||
from .base import BaseTrophyChecker
|
||||
|
||||
|
||||
class VolumeChecker(BaseTrophyChecker):
|
||||
"""
|
||||
Checker for volume-based trophies.
|
||||
|
||||
Used for trophies that require lifting a cumulative amount of weight.
|
||||
|
||||
Expected params:
|
||||
kg (int|float): The total weight in kg required to earn the trophy
|
||||
|
||||
Example:
|
||||
Lifter trophy: params={'kg': 5000}
|
||||
Atlas trophy: params={'kg': 100000}
|
||||
"""
|
||||
|
||||
def validate_params(self) -> bool:
|
||||
"""Validate that kg parameter is present and valid."""
|
||||
kg = self.params.get('kg')
|
||||
return kg is not None and isinstance(kg, (int, float)) and kg > 0
|
||||
|
||||
def check(self) -> bool:
|
||||
"""Check if user has lifted the required total weight."""
|
||||
if not self.validate_params():
|
||||
return False
|
||||
return self.get_current_value() >= self.get_target_value()
|
||||
|
||||
def get_progress(self) -> float:
|
||||
"""Get progress as percentage of weight lifted."""
|
||||
if not self.validate_params():
|
||||
return 0.0
|
||||
target = self.get_target_value()
|
||||
if target <= 0:
|
||||
return 0.0
|
||||
current = float(self.get_current_value())
|
||||
progress = (current / target) * 100
|
||||
return min(progress, 100.0)
|
||||
|
||||
def get_target_value(self) -> float:
|
||||
"""Get the target weight in kg."""
|
||||
return float(self.params.get('kg', 0))
|
||||
|
||||
def get_current_value(self) -> Decimal:
|
||||
"""Get the user's total weight lifted in kg."""
|
||||
return self.statistics.total_weight_lifted
|
||||
|
||||
def get_progress_display(self) -> str:
|
||||
"""Get human-readable progress string."""
|
||||
current = self.get_current_value()
|
||||
target = self.get_target_value()
|
||||
|
||||
# Format large numbers with commas for readability
|
||||
if current >= 1000:
|
||||
current_str = f'{current:,.0f}'
|
||||
else:
|
||||
current_str = f'{current:.1f}'
|
||||
|
||||
if target >= 1000:
|
||||
target_str = f'{target:,.0f}'
|
||||
else:
|
||||
target_str = f'{target:.1f}'
|
||||
|
||||
return f'{current_str} / {target_str} kg'
|
||||
72
wger/trophies/checkers/weekend_warrior.py
Normal file
72
wger/trophies/checkers/weekend_warrior.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
from typing import Any
|
||||
|
||||
# Local
|
||||
from .base import BaseTrophyChecker
|
||||
|
||||
|
||||
class WeekendWarriorChecker(BaseTrophyChecker):
|
||||
"""
|
||||
Checker for Weekend Warrior trophy.
|
||||
|
||||
Used for trophies that require working out on both Saturday and Sunday
|
||||
for consecutive weekends.
|
||||
|
||||
Expected params:
|
||||
weekends (int): The number of consecutive complete weekends required
|
||||
|
||||
Example:
|
||||
Weekend Warrior trophy: params={'weekends': 4}
|
||||
"""
|
||||
|
||||
def validate_params(self) -> bool:
|
||||
"""Validate that weekends parameter is present and valid."""
|
||||
weekends = self.params.get('weekends')
|
||||
return weekends is not None and isinstance(weekends, int) and weekends > 0
|
||||
|
||||
def check(self) -> bool:
|
||||
"""Check if user has completed workouts on required consecutive weekends."""
|
||||
if not self.validate_params():
|
||||
return False
|
||||
return self.get_current_value() >= self.get_target_value()
|
||||
|
||||
def get_progress(self) -> float:
|
||||
"""Get progress as percentage of weekend streak achieved."""
|
||||
if not self.validate_params():
|
||||
return 0.0
|
||||
target = self.get_target_value()
|
||||
if target <= 0:
|
||||
return 0.0
|
||||
current = self.get_current_value()
|
||||
progress = (current / target) * 100
|
||||
return min(progress, 100.0)
|
||||
|
||||
def get_target_value(self) -> int:
|
||||
"""Get the target number of consecutive complete weekends."""
|
||||
return self.params.get('weekends', 0)
|
||||
|
||||
def get_current_value(self) -> int:
|
||||
"""Get the user's current weekend workout streak."""
|
||||
return self.statistics.weekend_workout_streak
|
||||
|
||||
def get_progress_display(self) -> str:
|
||||
"""Get human-readable progress string."""
|
||||
current = self.get_current_value()
|
||||
target = self.get_target_value()
|
||||
return f'{current} / {target} weekends'
|
||||
137
wger/trophies/fixtures/initial_trophies.json
Normal file
137
wger/trophies/fixtures/initial_trophies.json
Normal file
@@ -0,0 +1,137 @@
|
||||
[
|
||||
{
|
||||
"model": "trophies.trophy",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Beginner",
|
||||
"description": "Complete your first workout",
|
||||
"trophy_type": "count",
|
||||
"checker_class": "count_based",
|
||||
"checker_params": {"count": 1},
|
||||
"is_hidden": false,
|
||||
"is_progressive": false,
|
||||
"is_active": true,
|
||||
"order": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "trophies.trophy",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Unstoppable",
|
||||
"description": "Maintain a 30-day workout streak",
|
||||
"trophy_type": "sequence",
|
||||
"checker_class": "streak",
|
||||
"checker_params": {"days": 30},
|
||||
"is_hidden": false,
|
||||
"is_progressive": true,
|
||||
"is_active": true,
|
||||
"order": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "trophies.trophy",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "Weekend Warrior",
|
||||
"description": "Work out on Saturday and Sunday for 4 consecutive weekends",
|
||||
"trophy_type": "sequence",
|
||||
"checker_class": "weekend_warrior",
|
||||
"checker_params": {"weekends": 4},
|
||||
"is_hidden": false,
|
||||
"is_progressive": true,
|
||||
"is_active": true,
|
||||
"order": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "trophies.trophy",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "Lifter",
|
||||
"description": "Lift a cumulative total of 5,000 kg",
|
||||
"trophy_type": "volume",
|
||||
"checker_class": "volume",
|
||||
"checker_params": {"kg": 5000},
|
||||
"is_hidden": false,
|
||||
"is_progressive": true,
|
||||
"is_active": true,
|
||||
"order": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "trophies.trophy",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "Atlas",
|
||||
"description": "Lift a cumulative total of 100,000 kg",
|
||||
"trophy_type": "volume",
|
||||
"checker_class": "volume",
|
||||
"checker_params": {"kg": 100000},
|
||||
"is_hidden": false,
|
||||
"is_progressive": true,
|
||||
"is_active": true,
|
||||
"order": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "trophies.trophy",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"name": "Early Bird",
|
||||
"description": "Complete a workout before 6:00 AM",
|
||||
"trophy_type": "time",
|
||||
"checker_class": "time_based",
|
||||
"checker_params": {"before": "06:00"},
|
||||
"is_hidden": false,
|
||||
"is_progressive": false,
|
||||
"is_active": true,
|
||||
"order": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "trophies.trophy",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"name": "Night Owl",
|
||||
"description": "Complete a workout after 9:00 PM",
|
||||
"trophy_type": "time",
|
||||
"checker_class": "time_based",
|
||||
"checker_params": {"after": "21:00"},
|
||||
"is_hidden": false,
|
||||
"is_progressive": false,
|
||||
"is_active": true,
|
||||
"order": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "trophies.trophy",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"name": "New Year, New Me",
|
||||
"description": "Work out on January 1st",
|
||||
"trophy_type": "date",
|
||||
"checker_class": "date_based",
|
||||
"checker_params": {"month": 1, "day": 1},
|
||||
"is_hidden": false,
|
||||
"is_progressive": false,
|
||||
"is_active": true,
|
||||
"order": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "trophies.trophy",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"name": "Phoenix",
|
||||
"description": "Return to training after being inactive for 30 days",
|
||||
"trophy_type": "other",
|
||||
"checker_class": "inactivity_return",
|
||||
"checker_params": {"inactive_days": 30},
|
||||
"is_hidden": true,
|
||||
"is_progressive": false,
|
||||
"is_active": true,
|
||||
"order": 9
|
||||
}
|
||||
}
|
||||
]
|
||||
15
wger/trophies/management/__init__.py
Normal file
15
wger/trophies/management/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
15
wger/trophies/management/commands/__init__.py
Normal file
15
wger/trophies/management/commands/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
148
wger/trophies/management/commands/evaluate_trophies.py
Normal file
148
wger/trophies/management/commands/evaluate_trophies.py
Normal file
@@ -0,0 +1,148 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Django
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import (
|
||||
BaseCommand,
|
||||
CommandError,
|
||||
)
|
||||
|
||||
# wger
|
||||
from wger.trophies.models import Trophy
|
||||
from wger.trophies.services.trophy import TrophyService
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Manually trigger trophy evaluation for users.
|
||||
|
||||
This command allows administrators to evaluate trophies for specific
|
||||
users, specific trophies, or all trophies for all users.
|
||||
"""
|
||||
|
||||
help = 'Evaluate trophies for users'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--user',
|
||||
type=str,
|
||||
dest='username',
|
||||
help='Username of the user to evaluate trophies for',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--trophy',
|
||||
type=int,
|
||||
dest='trophy_id',
|
||||
help='ID of a specific trophy to evaluate',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--all',
|
||||
action='store_true',
|
||||
dest='all',
|
||||
default=False,
|
||||
help='Evaluate all trophies for all active users',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--force-reevaluate',
|
||||
action='store_true',
|
||||
dest='force_reevaluate',
|
||||
default=False,
|
||||
help='Re-evaluate all trophies, including already earned ones',
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
"""
|
||||
Process the trophy evaluation based on provided options.
|
||||
"""
|
||||
verbosity = int(options['verbosity'])
|
||||
username = options['username']
|
||||
trophy_id = options['trophy_id']
|
||||
evaluate_all = options['all']
|
||||
force_reevaluate = options['force_reevaluate']
|
||||
|
||||
# Validate that at least one option is provided
|
||||
if not username and not trophy_id and not evaluate_all:
|
||||
raise CommandError(
|
||||
'Please specify --user, --trophy, or --all. See help for details.'
|
||||
)
|
||||
|
||||
# Case 1: Evaluate for a specific user
|
||||
if username:
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
raise CommandError(f'User "{username}" does not exist')
|
||||
|
||||
if verbosity >= 1:
|
||||
self.stdout.write(f'Evaluating trophies for user: {username}')
|
||||
|
||||
awarded = TrophyService.evaluate_all_trophies(user)
|
||||
|
||||
if verbosity >= 2:
|
||||
for user_trophy in awarded:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'✓ Awarded trophy "{user_trophy.trophy.name}" to {username}'
|
||||
)
|
||||
)
|
||||
|
||||
if verbosity >= 1:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nEvaluation complete: {len(awarded)} trophy(ies) awarded'
|
||||
)
|
||||
)
|
||||
|
||||
# Case 2: Evaluate a specific trophy for all users (or force re-evaluation)
|
||||
elif trophy_id or evaluate_all or force_reevaluate:
|
||||
trophy_ids = None
|
||||
if trophy_id:
|
||||
# Validate trophy exists
|
||||
try:
|
||||
trophy = Trophy.objects.get(id=trophy_id)
|
||||
trophy_ids = [trophy_id]
|
||||
|
||||
if verbosity >= 1:
|
||||
self.stdout.write(
|
||||
f'Evaluating trophy "{trophy.name}" for all users'
|
||||
)
|
||||
except Trophy.DoesNotExist:
|
||||
raise CommandError(f'Trophy with ID {trophy_id} does not exist')
|
||||
else:
|
||||
if verbosity >= 1:
|
||||
self.stdout.write('Evaluating all trophies for all users')
|
||||
|
||||
# Use the reevaluate service method
|
||||
results = TrophyService.reevaluate_trophies(
|
||||
trophy_ids=trophy_ids,
|
||||
user_ids=None, # All active users
|
||||
)
|
||||
|
||||
if verbosity >= 1:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nEvaluation complete:\n'
|
||||
f' Users checked: {results["users_checked"]}\n'
|
||||
f' Trophies awarded: {results["trophies_awarded"]}'
|
||||
)
|
||||
)
|
||||
|
||||
# Case 3: Force re-evaluation (handled above)
|
||||
# The force_reevaluate flag is implicit in using reevaluate_trophies method
|
||||
196
wger/trophies/management/commands/load_trophies.py
Normal file
196
wger/trophies/management/commands/load_trophies.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Django
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# wger
|
||||
from wger.trophies.models import Trophy
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Load initial trophy definitions into the database.
|
||||
|
||||
This command is idempotent - it can be run multiple times safely.
|
||||
Existing trophies with the same name will be updated, not duplicated.
|
||||
"""
|
||||
|
||||
help = 'Load initial trophy definitions into the database'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--update',
|
||||
action='store_true',
|
||||
dest='update',
|
||||
default=False,
|
||||
help='Update existing trophies if they already exist',
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
"""
|
||||
Load the initial trophy definitions.
|
||||
"""
|
||||
verbosity = int(options['verbosity'])
|
||||
update_existing = options['update']
|
||||
|
||||
# Define the initial trophies
|
||||
trophies_data = [
|
||||
{
|
||||
'name': _('Beginner'),
|
||||
'description': _('Complete your first workout'),
|
||||
'trophy_type': Trophy.TYPE_COUNT,
|
||||
'checker_class': 'count_based',
|
||||
'checker_params': {'count': 1},
|
||||
'is_hidden': False,
|
||||
'is_progressive': False,
|
||||
'order': 1,
|
||||
},
|
||||
{
|
||||
'name': _('Unstoppable'),
|
||||
'description': _('Maintain a 30-day workout streak'),
|
||||
'trophy_type': Trophy.TYPE_SEQUENCE,
|
||||
'checker_class': 'streak',
|
||||
'checker_params': {'days': 30},
|
||||
'is_hidden': False,
|
||||
'is_progressive': True,
|
||||
'order': 2,
|
||||
},
|
||||
{
|
||||
'name': _('Weekend Warrior'),
|
||||
'description': _('Work out on Saturday and Sunday for 4 consecutive weekends'),
|
||||
'trophy_type': Trophy.TYPE_SEQUENCE,
|
||||
'checker_class': 'weekend_warrior',
|
||||
'checker_params': {'weekends': 4},
|
||||
'is_hidden': False,
|
||||
'is_progressive': True,
|
||||
'order': 3,
|
||||
},
|
||||
{
|
||||
'name': _('Lifter'),
|
||||
'description': _('Lift a cumulative total of 5,000 kg'),
|
||||
'trophy_type': Trophy.TYPE_VOLUME,
|
||||
'checker_class': 'volume',
|
||||
'checker_params': {'kg': 5000},
|
||||
'is_hidden': False,
|
||||
'is_progressive': True,
|
||||
'order': 4,
|
||||
},
|
||||
{
|
||||
'name': _('Atlas'),
|
||||
'description': _('Lift a cumulative total of 100,000 kg'),
|
||||
'trophy_type': Trophy.TYPE_VOLUME,
|
||||
'checker_class': 'volume',
|
||||
'checker_params': {'kg': 100000},
|
||||
'is_hidden': False,
|
||||
'is_progressive': True,
|
||||
'order': 5,
|
||||
},
|
||||
{
|
||||
'name': _('Early Bird'),
|
||||
'description': _('Complete a workout before 6:00 AM'),
|
||||
'trophy_type': Trophy.TYPE_TIME,
|
||||
'checker_class': 'time_based',
|
||||
'checker_params': {'before': '06:00'},
|
||||
'is_hidden': False,
|
||||
'is_progressive': False,
|
||||
'order': 6,
|
||||
},
|
||||
{
|
||||
'name': _('Night Owl'),
|
||||
'description': _('Complete a workout after 9:00 PM'),
|
||||
'trophy_type': Trophy.TYPE_TIME,
|
||||
'checker_class': 'time_based',
|
||||
'checker_params': {'after': '21:00'},
|
||||
'is_hidden': False,
|
||||
'is_progressive': False,
|
||||
'order': 7,
|
||||
},
|
||||
{
|
||||
'name': _('New Year, New Me'),
|
||||
'description': _('Work out on January 1st'),
|
||||
'trophy_type': Trophy.TYPE_DATE,
|
||||
'checker_class': 'date_based',
|
||||
'checker_params': {'month': 1, 'day': 1},
|
||||
'is_hidden': False,
|
||||
'is_progressive': False,
|
||||
'order': 8,
|
||||
},
|
||||
{
|
||||
'name': _('Phoenix'),
|
||||
'description': _('Return to training after being inactive for 30 days'),
|
||||
'trophy_type': Trophy.TYPE_OTHER,
|
||||
'checker_class': 'inactivity_return',
|
||||
'checker_params': {'inactive_days': 30},
|
||||
'is_hidden': True,
|
||||
'is_progressive': False,
|
||||
'order': 9,
|
||||
},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for trophy_data in trophies_data:
|
||||
# Convert lazy translation to string for database lookup
|
||||
name = str(trophy_data['name'])
|
||||
|
||||
# Check if trophy already exists
|
||||
existing = Trophy.objects.filter(name=name).first()
|
||||
|
||||
if existing:
|
||||
if update_existing:
|
||||
# Update existing trophy
|
||||
for key, value in trophy_data.items():
|
||||
if key != 'name': # Don't update the name
|
||||
setattr(existing, key, value)
|
||||
existing.save()
|
||||
updated_count += 1
|
||||
|
||||
if verbosity >= 2:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'✓ Updated trophy: {name}')
|
||||
)
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
if verbosity >= 2:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'- Skipped existing trophy: {name}')
|
||||
)
|
||||
else:
|
||||
# Create new trophy
|
||||
Trophy.objects.create(**trophy_data)
|
||||
created_count += 1
|
||||
|
||||
if verbosity >= 2:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'+ Created trophy: {name}')
|
||||
)
|
||||
|
||||
# Summary
|
||||
if verbosity >= 1:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nTrophy loading complete:\n'
|
||||
f' Created: {created_count}\n'
|
||||
f' Updated: {updated_count}\n'
|
||||
f' Skipped: {skipped_count}\n'
|
||||
f' Total: {len(trophies_data)}'
|
||||
)
|
||||
)
|
||||
164
wger/trophies/management/commands/recalculate_statistics.py
Normal file
164
wger/trophies/management/commands/recalculate_statistics.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
from datetime import timedelta
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import (
|
||||
BaseCommand,
|
||||
CommandError,
|
||||
)
|
||||
from django.utils import timezone
|
||||
|
||||
# wger
|
||||
from wger.trophies.services.statistics import UserStatisticsService
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Recalculate user statistics from workout history.
|
||||
|
||||
This command performs a full recalculation of UserStatistics for
|
||||
specified users by analyzing their complete workout history.
|
||||
"""
|
||||
|
||||
help = 'Recalculate user statistics from workout history'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--user',
|
||||
type=str,
|
||||
dest='username',
|
||||
help='Username of the user to recalculate statistics for',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--all',
|
||||
action='store_true',
|
||||
dest='all',
|
||||
default=False,
|
||||
help='Recalculate statistics for all users',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--active-only',
|
||||
action='store_true',
|
||||
dest='active_only',
|
||||
default=False,
|
||||
help='Only process users who logged in recently (with --all)',
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
"""
|
||||
Process the statistics recalculation based on provided options.
|
||||
"""
|
||||
verbosity = int(options['verbosity'])
|
||||
username = options['username']
|
||||
recalculate_all = options['all']
|
||||
active_only = options['active_only']
|
||||
|
||||
# Validate that at least one option is provided
|
||||
if not username and not recalculate_all:
|
||||
raise CommandError(
|
||||
'Please specify --user or --all. See help for details.'
|
||||
)
|
||||
|
||||
# Case 1: Recalculate for a specific user
|
||||
if username:
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
raise CommandError(f'User "{username}" does not exist')
|
||||
|
||||
if verbosity >= 1:
|
||||
self.stdout.write(f'Recalculating statistics for user: {username}')
|
||||
|
||||
stats = UserStatisticsService.update_statistics(user)
|
||||
|
||||
if verbosity >= 2:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\n✓ Statistics updated:\n'
|
||||
f' Total workouts: {stats.total_workouts}\n'
|
||||
f' Total weight lifted: {stats.total_weight_lifted} kg\n'
|
||||
f' Current streak: {stats.current_streak} days\n'
|
||||
f' Longest streak: {stats.longest_streak} days\n'
|
||||
f' Weekend streak: {stats.weekend_workout_streak} weekends'
|
||||
)
|
||||
)
|
||||
|
||||
if verbosity >= 1:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'\nRecalculation complete for {username}')
|
||||
)
|
||||
|
||||
# Case 2: Recalculate for all users
|
||||
elif recalculate_all:
|
||||
# Get users to process
|
||||
users = User.objects.all()
|
||||
|
||||
if active_only:
|
||||
# Only process users who logged in recently
|
||||
inactive_days = settings.WGER_SETTINGS.get('TROPHIES_INACTIVE_USER_DAYS', 30)
|
||||
inactive_threshold = timezone.now() - timedelta(days=inactive_days)
|
||||
users = users.filter(last_login__gte=inactive_threshold)
|
||||
|
||||
if verbosity >= 1:
|
||||
self.stdout.write(
|
||||
f'Recalculating statistics for active users '
|
||||
f'(logged in within {inactive_days} days)'
|
||||
)
|
||||
else:
|
||||
if verbosity >= 1:
|
||||
self.stdout.write('Recalculating statistics for all users')
|
||||
|
||||
total_users = users.count()
|
||||
processed = 0
|
||||
errors = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
UserStatisticsService.update_statistics(user)
|
||||
processed += 1
|
||||
|
||||
if verbosity >= 2:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'✓ Processed: {user.username}')
|
||||
)
|
||||
elif verbosity >= 1 and processed % 100 == 0:
|
||||
self.stdout.write(f' Processed {processed}/{total_users} users...')
|
||||
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
if verbosity >= 1:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f'✗ Error processing {user.username}: {str(e)}'
|
||||
)
|
||||
)
|
||||
|
||||
if verbosity >= 1:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nRecalculation complete:\n'
|
||||
f' Total users: {total_users}\n'
|
||||
f' Processed: {processed}\n'
|
||||
f' Errors: {errors}'
|
||||
)
|
||||
)
|
||||
83
wger/trophies/migrations/0001_initial.py
Normal file
83
wger/trophies/migrations/0001_initial.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-25 10:54
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
import wger.trophies.models.trophy
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Trophy',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('name', models.CharField(help_text='The name of the trophy', max_length=100, verbose_name='Name')),
|
||||
('description', models.TextField(blank=True, default='', help_text='A description of how to earn this trophy', verbose_name='Description')),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to=wger.trophies.models.trophy.trophy_image_upload_path, verbose_name='Image')),
|
||||
('trophy_type', models.CharField(choices=[('time', 'Time-based'), ('volume', 'Volume-based'), ('count', 'Count-based'), ('sequence', 'Sequence-based'), ('date', 'Date-based'), ('other', 'Other')], default='other', help_text='The type of criteria used to evaluate this trophy', max_length=20, verbose_name='Trophy type')),
|
||||
('checker_class', models.CharField(help_text='The Python class path used to check if this trophy is earned', max_length=255, verbose_name='Checker class')),
|
||||
('checker_params', models.JSONField(blank=True, default=dict, help_text='JSON parameters passed to the checker class', verbose_name='Checker parameters')),
|
||||
('is_hidden', models.BooleanField(default=False, help_text='If true, this trophy is hidden until earned', verbose_name='Hidden')),
|
||||
('is_progressive', models.BooleanField(default=False, help_text='If true, this trophy shows progress towards completion', verbose_name='Progressive')),
|
||||
('is_active', models.BooleanField(default=True, help_text='If false, this trophy cannot be earned', verbose_name='Active')),
|
||||
('order', models.PositiveIntegerField(default=0, help_text='Display order of the trophy', verbose_name='Order')),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Trophy',
|
||||
'verbose_name_plural': 'Trophies',
|
||||
'ordering': ['order', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserStatistics',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('total_weight_lifted', models.DecimalField(decimal_places=2, default=0, help_text='Cumulative weight lifted in kg', max_digits=12, verbose_name='Total weight lifted')),
|
||||
('total_workouts', models.PositiveIntegerField(default=0, help_text='Total number of workout sessions completed', verbose_name='Total workouts')),
|
||||
('current_streak', models.PositiveIntegerField(default=0, help_text='Current consecutive days with workouts', verbose_name='Current streak')),
|
||||
('longest_streak', models.PositiveIntegerField(default=0, help_text='Longest consecutive days with workouts', verbose_name='Longest streak')),
|
||||
('last_workout_date', models.DateField(blank=True, help_text='Date of the most recent workout', null=True, verbose_name='Last workout date')),
|
||||
('earliest_workout_time', models.TimeField(blank=True, help_text='Earliest time a workout was started', null=True, verbose_name='Earliest workout time')),
|
||||
('latest_workout_time', models.TimeField(blank=True, help_text='Latest time a workout was started', null=True, verbose_name='Latest workout time')),
|
||||
('weekend_workout_streak', models.PositiveIntegerField(default=0, help_text='Consecutive weekends with workouts on both Saturday and Sunday', verbose_name='Weekend workout streak')),
|
||||
('last_inactive_date', models.DateField(blank=True, help_text='Last date before the current activity period began', null=True, verbose_name='Last inactive date')),
|
||||
('worked_out_jan_1', models.BooleanField(default=False, help_text='Whether user has ever worked out on January 1st', verbose_name='Worked out on January 1st')),
|
||||
('last_updated', models.DateTimeField(auto_now=True, verbose_name='Last updated')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='trophy_statistics', to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User statistics',
|
||||
'verbose_name_plural': 'User statistics',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserTrophy',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('earned_at', models.DateTimeField(auto_now_add=True, help_text='When the trophy was earned', verbose_name='Earned at')),
|
||||
('progress', models.FloatField(default=0.0, help_text='Progress towards earning the trophy (0-100)', validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(100.0)], verbose_name='Progress')),
|
||||
('is_notified', models.BooleanField(default=False, help_text='Whether the user has been notified about earning this trophy', verbose_name='Notified')),
|
||||
('trophy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_trophies', to='trophies.trophy', verbose_name='Trophy')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='earned_trophies', to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User trophy',
|
||||
'verbose_name_plural': 'User trophies',
|
||||
'ordering': ['-earned_at'],
|
||||
'unique_together': {('user', 'trophy')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
158
wger/trophies/migrations/0003_load_initial_trophies.py
Normal file
158
wger/trophies/migrations/0003_load_initial_trophies.py
Normal file
@@ -0,0 +1,158 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-03 18:33
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def load_initial_trophies(apps, schema_editor):
|
||||
"""
|
||||
Load the initial set of 9 trophies.
|
||||
|
||||
This migration is idempotent - it will skip trophies that already exist
|
||||
based on the trophy name.
|
||||
"""
|
||||
Trophy = apps.get_model('trophies', 'Trophy')
|
||||
|
||||
# Define the initial trophies (same as in load_trophies management command)
|
||||
trophies_data = [
|
||||
{
|
||||
'name': 'Beginner',
|
||||
'description': 'Complete your first workout',
|
||||
'trophy_type': 'count',
|
||||
'checker_class': 'count_based',
|
||||
'checker_params': {'count': 1},
|
||||
'is_hidden': False,
|
||||
'is_progressive': False,
|
||||
'order': 1,
|
||||
},
|
||||
{
|
||||
'name': 'Unstoppable',
|
||||
'description': 'Maintain a 30-day workout streak',
|
||||
'trophy_type': 'sequence',
|
||||
'checker_class': 'streak',
|
||||
'checker_params': {'days': 30},
|
||||
'is_hidden': False,
|
||||
'is_progressive': True,
|
||||
'order': 2,
|
||||
},
|
||||
{
|
||||
'name': 'Weekend Warrior',
|
||||
'description': 'Work out on Saturday and Sunday for 4 consecutive weekends',
|
||||
'trophy_type': 'sequence',
|
||||
'checker_class': 'weekend_warrior',
|
||||
'checker_params': {'weekends': 4},
|
||||
'is_hidden': False,
|
||||
'is_progressive': True,
|
||||
'order': 3,
|
||||
},
|
||||
{
|
||||
'name': 'Lifter',
|
||||
'description': 'Lift a cumulative total of 5,000 kg',
|
||||
'trophy_type': 'volume',
|
||||
'checker_class': 'volume',
|
||||
'checker_params': {'kg': 5000},
|
||||
'is_hidden': False,
|
||||
'is_progressive': True,
|
||||
'order': 4,
|
||||
},
|
||||
{
|
||||
'name': 'Atlas',
|
||||
'description': 'Lift a cumulative total of 100,000 kg',
|
||||
'trophy_type': 'volume',
|
||||
'checker_class': 'volume',
|
||||
'checker_params': {'kg': 100000},
|
||||
'is_hidden': False,
|
||||
'is_progressive': True,
|
||||
'order': 5,
|
||||
},
|
||||
{
|
||||
'name': 'Early Bird',
|
||||
'description': 'Complete a workout before 6:00 AM',
|
||||
'trophy_type': 'time',
|
||||
'checker_class': 'time_based',
|
||||
'checker_params': {'before': '06:00'},
|
||||
'is_hidden': False,
|
||||
'is_progressive': False,
|
||||
'order': 6,
|
||||
},
|
||||
{
|
||||
'name': 'Night Owl',
|
||||
'description': 'Complete a workout after 9:00 PM',
|
||||
'trophy_type': 'time',
|
||||
'checker_class': 'time_based',
|
||||
'checker_params': {'after': '21:00'},
|
||||
'is_hidden': False,
|
||||
'is_progressive': False,
|
||||
'order': 7,
|
||||
},
|
||||
{
|
||||
'name': 'New Year, New Me',
|
||||
'description': 'Work out on January 1st',
|
||||
'trophy_type': 'date',
|
||||
'checker_class': 'date_based',
|
||||
'checker_params': {'month': 1, 'day': 1},
|
||||
'is_hidden': False,
|
||||
'is_progressive': False,
|
||||
'order': 8,
|
||||
},
|
||||
{
|
||||
'name': 'Phoenix',
|
||||
'description': 'Return to training after being inactive for 30 days',
|
||||
'trophy_type': 'other',
|
||||
'checker_class': 'inactivity_return',
|
||||
'checker_params': {'inactive_days': 30},
|
||||
'is_hidden': True,
|
||||
'is_progressive': False,
|
||||
'order': 9,
|
||||
},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for trophy_data in trophies_data:
|
||||
# Check if trophy already exists
|
||||
if not Trophy.objects.filter(name=trophy_data['name']).exists():
|
||||
Trophy.objects.create(**trophy_data)
|
||||
created_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
if created_count > 0:
|
||||
print(f'Trophy migration: Created {created_count} trophies, skipped {skipped_count}')
|
||||
|
||||
|
||||
def reverse_load_trophies(apps, schema_editor):
|
||||
"""
|
||||
Reverse migration - delete the initial trophies.
|
||||
|
||||
This is optional and can be left as a no-op if you want to keep
|
||||
trophies even when rolling back migrations.
|
||||
"""
|
||||
Trophy = apps.get_model('trophies', 'Trophy')
|
||||
|
||||
trophy_names = [
|
||||
'Beginner',
|
||||
'Unstoppable',
|
||||
'Weekend Warrior',
|
||||
'Lifter',
|
||||
'Atlas',
|
||||
'Early Bird',
|
||||
'Night Owl',
|
||||
'New Year, New Me',
|
||||
'Phoenix',
|
||||
]
|
||||
|
||||
deleted_count = Trophy.objects.filter(name__in=trophy_names).delete()[0]
|
||||
if deleted_count > 0:
|
||||
print(f'Trophy migration reverse: Deleted {deleted_count} trophies')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('trophies', '0002_add_last_complete_weekend_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(load_initial_trophies, reverse_load_trophies),
|
||||
]
|
||||
0
wger/trophies/migrations/__init__.py
Normal file
0
wger/trophies/migrations/__init__.py
Normal file
20
wger/trophies/models/__init__.py
Normal file
20
wger/trophies/models/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Local
|
||||
from .trophy import Trophy
|
||||
from .user_statistics import UserStatistics
|
||||
from .user_trophy import UserTrophy
|
||||
160
wger/trophies/models/trophy.py
Normal file
160
wger/trophies/models/trophy.py
Normal file
@@ -0,0 +1,160 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
import uuid
|
||||
|
||||
# Django
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def trophy_image_upload_path(instance, filename):
|
||||
"""
|
||||
Returns the upload path for trophy images
|
||||
"""
|
||||
ext = filename.split('.')[-1]
|
||||
return f'trophies/{instance.uuid}.{ext}'
|
||||
|
||||
|
||||
class Trophy(models.Model):
|
||||
"""
|
||||
Model representing a trophy/achievement that users can earn
|
||||
"""
|
||||
|
||||
TYPE_TIME = 'time'
|
||||
TYPE_VOLUME = 'volume'
|
||||
TYPE_COUNT = 'count'
|
||||
TYPE_SEQUENCE = 'sequence'
|
||||
TYPE_DATE = 'date'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
TROPHY_TYPES = (
|
||||
(TYPE_TIME, _('Time-based')),
|
||||
(TYPE_VOLUME, _('Volume-based')),
|
||||
(TYPE_COUNT, _('Count-based')),
|
||||
(TYPE_SEQUENCE, _('Sequence-based')),
|
||||
(TYPE_DATE, _('Date-based')),
|
||||
(TYPE_OTHER, _('Other')),
|
||||
)
|
||||
|
||||
uuid = models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
unique=True,
|
||||
)
|
||||
"""Unique identifier for the trophy"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name=_('Name'),
|
||||
help_text=_('The name of the trophy'),
|
||||
)
|
||||
"""The name of the trophy"""
|
||||
|
||||
description = models.TextField(
|
||||
verbose_name=_('Description'),
|
||||
help_text=_('A description of how to earn this trophy'),
|
||||
blank=True,
|
||||
default='',
|
||||
)
|
||||
"""Description of the trophy and how to earn it"""
|
||||
|
||||
image = models.ImageField(
|
||||
verbose_name=_('Image'),
|
||||
upload_to=trophy_image_upload_path,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
"""Optional image for the trophy"""
|
||||
|
||||
trophy_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TROPHY_TYPES,
|
||||
default=TYPE_OTHER,
|
||||
verbose_name=_('Trophy type'),
|
||||
help_text=_('The type of criteria used to evaluate this trophy'),
|
||||
)
|
||||
"""The type of trophy (time, volume, count, sequence, date, other)"""
|
||||
|
||||
checker_class = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Checker class'),
|
||||
help_text=_('The Python class path used to check if this trophy is earned'),
|
||||
)
|
||||
"""Python path to the checker class (e.g., 'wger.trophies.checkers.CountBasedChecker')"""
|
||||
|
||||
checker_params = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
verbose_name=_('Checker parameters'),
|
||||
help_text=_('JSON parameters passed to the checker class'),
|
||||
)
|
||||
"""Parameters for the checker class (e.g., {'count': 1} for workout count)"""
|
||||
|
||||
is_hidden = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Hidden'),
|
||||
help_text=_('If true, this trophy is hidden until earned'),
|
||||
)
|
||||
"""Whether the trophy is hidden until earned"""
|
||||
|
||||
is_progressive = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Progressive'),
|
||||
help_text=_('If true, this trophy shows progress towards completion'),
|
||||
)
|
||||
"""Whether to show progress towards earning the trophy"""
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('Active'),
|
||||
help_text=_('If false, this trophy cannot be earned'),
|
||||
)
|
||||
"""Whether the trophy is active and can be earned"""
|
||||
|
||||
order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_('Order'),
|
||||
help_text=_('Display order of the trophy'),
|
||||
)
|
||||
"""Display order for the trophy"""
|
||||
|
||||
created = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
editable=False,
|
||||
)
|
||||
"""When the trophy was created"""
|
||||
|
||||
updated = models.DateTimeField(
|
||||
auto_now=True,
|
||||
editable=False,
|
||||
)
|
||||
"""When the trophy was last updated"""
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'name']
|
||||
verbose_name = _('Trophy')
|
||||
verbose_name_plural = _('Trophies')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_owner_object(self):
|
||||
"""
|
||||
Trophies don't have an owner - they are global
|
||||
"""
|
||||
return None
|
||||
139
wger/trophies/models/user_statistics.py
Normal file
139
wger/trophies/models/user_statistics.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Django
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class UserStatistics(models.Model):
|
||||
"""
|
||||
Denormalized statistics table for trophy calculations.
|
||||
This table is updated incrementally as users log workouts to avoid
|
||||
expensive recalculations every time a trophy is evaluated.
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='trophy_statistics',
|
||||
verbose_name=_('User'),
|
||||
)
|
||||
"""The user these statistics belong to"""
|
||||
|
||||
total_weight_lifted = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name=_('Total weight lifted'),
|
||||
help_text=_('Cumulative weight lifted in kg'),
|
||||
)
|
||||
"""Total cumulative weight lifted in kg"""
|
||||
|
||||
total_workouts = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_('Total workouts'),
|
||||
help_text=_('Total number of workout sessions completed'),
|
||||
)
|
||||
"""Total number of workout sessions completed"""
|
||||
|
||||
current_streak = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_('Current streak'),
|
||||
help_text=_('Current consecutive days with workouts'),
|
||||
)
|
||||
"""Current consecutive workout streak in days"""
|
||||
|
||||
longest_streak = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_('Longest streak'),
|
||||
help_text=_('Longest consecutive days with workouts'),
|
||||
)
|
||||
"""Longest consecutive workout streak ever achieved"""
|
||||
|
||||
last_workout_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('Last workout date'),
|
||||
help_text=_('Date of the most recent workout'),
|
||||
)
|
||||
"""Date of the most recent workout"""
|
||||
|
||||
earliest_workout_time = models.TimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('Earliest workout time'),
|
||||
help_text=_('Earliest time a workout was started'),
|
||||
)
|
||||
"""Earliest time a workout was ever started"""
|
||||
|
||||
latest_workout_time = models.TimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('Latest workout time'),
|
||||
help_text=_('Latest time a workout was started'),
|
||||
)
|
||||
"""Latest time a workout was ever started"""
|
||||
|
||||
weekend_workout_streak = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_('Weekend workout streak'),
|
||||
help_text=_('Consecutive weekends with workouts on both Saturday and Sunday'),
|
||||
)
|
||||
"""Consecutive weekends with workouts on both Saturday and Sunday"""
|
||||
|
||||
last_complete_weekend_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('Last complete weekend date'),
|
||||
help_text=_('Date of the last Saturday where both Sat and Sun had workouts'),
|
||||
)
|
||||
"""Used for tracking consecutive weekend workouts"""
|
||||
|
||||
last_inactive_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('Last inactive date'),
|
||||
help_text=_('Last date before the current activity period began'),
|
||||
)
|
||||
"""Used for Phoenix trophy - tracks when user was last inactive"""
|
||||
|
||||
worked_out_jan_1 = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Worked out on January 1st'),
|
||||
help_text=_('Whether user has ever worked out on January 1st'),
|
||||
)
|
||||
"""Flag for New Year, New Me trophy"""
|
||||
|
||||
last_updated = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name=_('Last updated'),
|
||||
)
|
||||
"""When these statistics were last updated"""
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('User statistics')
|
||||
verbose_name_plural = _('User statistics')
|
||||
|
||||
def __str__(self):
|
||||
return f'Statistics for {self.user.username}'
|
||||
|
||||
def get_owner_object(self):
|
||||
"""
|
||||
Returns the object that has owner information
|
||||
"""
|
||||
return self
|
||||
86
wger/trophies/models/user_trophy.py
Normal file
86
wger/trophies/models/user_trophy.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Django
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import (
|
||||
MaxValueValidator,
|
||||
MinValueValidator,
|
||||
)
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Local
|
||||
from .trophy import Trophy
|
||||
|
||||
|
||||
class UserTrophy(models.Model):
|
||||
"""
|
||||
Model representing a trophy earned by a user (M2M through table)
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='earned_trophies',
|
||||
verbose_name=_('User'),
|
||||
)
|
||||
"""The user who earned the trophy"""
|
||||
|
||||
trophy = models.ForeignKey(
|
||||
Trophy,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='user_trophies',
|
||||
verbose_name=_('Trophy'),
|
||||
)
|
||||
"""The trophy that was earned"""
|
||||
|
||||
earned_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name=_('Earned at'),
|
||||
help_text=_('When the trophy was earned'),
|
||||
)
|
||||
"""When the trophy was earned"""
|
||||
|
||||
progress = models.FloatField(
|
||||
default=0.0,
|
||||
validators=[MinValueValidator(0.0), MaxValueValidator(100.0)],
|
||||
verbose_name=_('Progress'),
|
||||
help_text=_('Progress towards earning the trophy (0-100)'),
|
||||
)
|
||||
"""Progress towards earning the trophy (0-100%)"""
|
||||
|
||||
is_notified = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Notified'),
|
||||
help_text=_('Whether the user has been notified about earning this trophy'),
|
||||
)
|
||||
"""Whether the user has been notified about this trophy (for future notification system)"""
|
||||
|
||||
class Meta:
|
||||
ordering = ['-earned_at']
|
||||
verbose_name = _('User trophy')
|
||||
verbose_name_plural = _('User trophies')
|
||||
unique_together = [['user', 'trophy']]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.username} - {self.trophy.name}'
|
||||
|
||||
def get_owner_object(self):
|
||||
"""
|
||||
Returns the object that has owner information
|
||||
"""
|
||||
return self
|
||||
20
wger/trophies/services/__init__.py
Normal file
20
wger/trophies/services/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from .statistics import UserStatisticsService
|
||||
from .trophy import TrophyService
|
||||
|
||||
__all__ = ['UserStatisticsService', 'TrophyService']
|
||||
477
wger/trophies/services/statistics.py
Normal file
477
wger/trophies/services/statistics.py
Normal file
@@ -0,0 +1,477 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
import datetime
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
# Django
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Sum
|
||||
|
||||
# wger
|
||||
from wger.manager.consts import WEIGHT_UNIT_LB
|
||||
from wger.manager.models import (
|
||||
WorkoutLog,
|
||||
WorkoutSession,
|
||||
)
|
||||
from wger.trophies.models import UserStatistics
|
||||
from wger.utils.units import AbstractWeight
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserStatisticsService:
|
||||
"""
|
||||
Service class for managing user trophy statistics.
|
||||
|
||||
This service handles:
|
||||
- Full recalculation of statistics from workout history
|
||||
- Incremental updates when new workouts are logged
|
||||
- Weight unit normalization (all stored in kg)
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_or_create_statistics(cls, user: User) -> UserStatistics:
|
||||
"""
|
||||
Get or create a UserStatistics record for the user.
|
||||
|
||||
Args:
|
||||
user: The user to get/create statistics for
|
||||
|
||||
Returns:
|
||||
The UserStatistics instance
|
||||
"""
|
||||
stats, created = UserStatistics.objects.get_or_create(user=user)
|
||||
return stats
|
||||
|
||||
@classmethod
|
||||
def update_statistics(cls, user: User) -> UserStatistics:
|
||||
"""
|
||||
Perform a full recalculation of user statistics from workout history.
|
||||
|
||||
This method recalculates all statistics from scratch by querying
|
||||
the user's complete workout history. Use this for:
|
||||
- Initial population of statistics
|
||||
- Recovery from data inconsistencies
|
||||
- After bulk data imports
|
||||
|
||||
Args:
|
||||
user: The user to update statistics for
|
||||
|
||||
Returns:
|
||||
The updated UserStatistics instance
|
||||
"""
|
||||
stats = cls.get_or_create_statistics(user)
|
||||
|
||||
# Get all workout logs for this user
|
||||
logs = WorkoutLog.objects.filter(user=user).select_related('weight_unit', 'session')
|
||||
|
||||
# Calculate total weight lifted (normalized to kg)
|
||||
total_weight = cls._calculate_total_weight(logs)
|
||||
stats.total_weight_lifted = total_weight
|
||||
|
||||
# Get all workout sessions
|
||||
sessions = WorkoutSession.objects.filter(user=user).order_by('date')
|
||||
stats.total_workouts = sessions.count()
|
||||
|
||||
# Calculate streaks and other date-based stats
|
||||
workout_dates = list(sessions.values_list('date', flat=True).distinct().order_by('date'))
|
||||
current_streak, longest_streak = cls._calculate_streaks(workout_dates)
|
||||
stats.current_streak = current_streak
|
||||
stats.longest_streak = longest_streak
|
||||
|
||||
# Set last workout date
|
||||
if workout_dates:
|
||||
stats.last_workout_date = workout_dates[-1]
|
||||
|
||||
# Calculate earliest and latest workout times
|
||||
earliest, latest = cls._calculate_workout_times(sessions)
|
||||
stats.earliest_workout_time = earliest
|
||||
stats.latest_workout_time = latest
|
||||
|
||||
# Calculate weekend workout streak
|
||||
weekend_streak, last_complete_weekend = cls._calculate_weekend_streak(workout_dates)
|
||||
stats.weekend_workout_streak = weekend_streak
|
||||
stats.last_complete_weekend_date = last_complete_weekend
|
||||
|
||||
# Check if user worked out on January 1st
|
||||
stats.worked_out_jan_1 = cls._check_jan_1_workout(workout_dates)
|
||||
|
||||
# Calculate last inactive date (for Phoenix trophy)
|
||||
stats.last_inactive_date = cls._calculate_last_inactive_date(workout_dates)
|
||||
|
||||
stats.save()
|
||||
return stats
|
||||
|
||||
@classmethod
|
||||
def increment_workout(
|
||||
cls,
|
||||
user: User,
|
||||
workout_log: Optional[WorkoutLog] = None,
|
||||
session: Optional[WorkoutSession] = None,
|
||||
) -> UserStatistics:
|
||||
"""
|
||||
Incrementally update statistics when a new workout is logged.
|
||||
|
||||
This method performs efficient incremental updates rather than
|
||||
full recalculation. It's called by signal handlers when:
|
||||
- A new WorkoutLog is created
|
||||
- A WorkoutSession is created/updated
|
||||
|
||||
Args:
|
||||
user: The user to update statistics for
|
||||
workout_log: The new workout log (if triggered by log creation)
|
||||
session: The workout session (if triggered by session creation)
|
||||
|
||||
Returns:
|
||||
The updated UserStatistics instance
|
||||
"""
|
||||
stats = cls.get_or_create_statistics(user)
|
||||
|
||||
# Update total weight if a log was provided
|
||||
if workout_log and workout_log.weight is not None:
|
||||
weight_kg = cls._normalize_weight(workout_log.weight, workout_log.weight_unit_id)
|
||||
reps = workout_log.repetitions or Decimal('1')
|
||||
volume = weight_kg * reps
|
||||
stats.total_weight_lifted += volume
|
||||
|
||||
# Get the session date
|
||||
session_date = None
|
||||
if session:
|
||||
session_date = session.date
|
||||
elif workout_log and workout_log.session:
|
||||
session_date = workout_log.session.date
|
||||
|
||||
if session_date:
|
||||
# Convert datetime to date if needed for comparison
|
||||
if hasattr(session_date, 'date'):
|
||||
session_date = session_date.date()
|
||||
|
||||
# Check if this is a new workout day
|
||||
is_new_day = stats.last_workout_date is None or session_date > stats.last_workout_date
|
||||
|
||||
if is_new_day:
|
||||
# Update streak
|
||||
if stats.last_workout_date:
|
||||
days_gap = (session_date - stats.last_workout_date).days
|
||||
if days_gap == 1:
|
||||
# Consecutive day - extend streak
|
||||
stats.current_streak += 1
|
||||
elif days_gap > 1:
|
||||
# Gap in workouts - check for Phoenix trophy trigger
|
||||
if days_gap >= 30:
|
||||
stats.last_inactive_date = stats.last_workout_date
|
||||
# Reset streak
|
||||
stats.current_streak = 1
|
||||
else:
|
||||
# First workout ever
|
||||
stats.current_streak = 1
|
||||
|
||||
# Update longest streak
|
||||
if stats.current_streak > stats.longest_streak:
|
||||
stats.longest_streak = stats.current_streak
|
||||
|
||||
# Update last workout date
|
||||
stats.last_workout_date = session_date
|
||||
|
||||
# Check for Jan 1st workout
|
||||
if session_date.month == 1 and session_date.day == 1:
|
||||
stats.worked_out_jan_1 = True
|
||||
|
||||
# Update weekend streak
|
||||
cls._update_weekend_streak_incremental(stats, session_date)
|
||||
|
||||
# Update workout times if session has time info
|
||||
if session and session.time_start:
|
||||
if stats.earliest_workout_time is None or session.time_start < stats.earliest_workout_time:
|
||||
stats.earliest_workout_time = session.time_start
|
||||
if stats.latest_workout_time is None or session.time_start > stats.latest_workout_time:
|
||||
stats.latest_workout_time = session.time_start
|
||||
|
||||
# Count sessions for total workouts (recalculate to be accurate)
|
||||
stats.total_workouts = WorkoutSession.objects.filter(user=user).count()
|
||||
|
||||
stats.save()
|
||||
return stats
|
||||
|
||||
@classmethod
|
||||
def handle_workout_deletion(cls, user: User) -> UserStatistics:
|
||||
"""
|
||||
Handle statistics update when a workout is deleted.
|
||||
|
||||
Since deletion can affect streaks and totals in complex ways,
|
||||
we perform a full recalculation.
|
||||
|
||||
Args:
|
||||
user: The user whose workout was deleted
|
||||
|
||||
Returns:
|
||||
The updated UserStatistics instance
|
||||
"""
|
||||
return cls.update_statistics(user)
|
||||
|
||||
@classmethod
|
||||
def _normalize_weight(cls, weight: Decimal, weight_unit_id: Optional[int]) -> Decimal:
|
||||
"""
|
||||
Convert weight to kg using AbstractWeight utility.
|
||||
|
||||
Args:
|
||||
weight: The weight value
|
||||
weight_unit_id: The weight unit ID (1=kg, 2=lb)
|
||||
|
||||
Returns:
|
||||
Weight in kg
|
||||
"""
|
||||
if weight is None:
|
||||
return Decimal('0')
|
||||
|
||||
mode = 'lb' if weight_unit_id == WEIGHT_UNIT_LB else 'kg'
|
||||
return AbstractWeight(weight, mode).kg
|
||||
|
||||
@classmethod
|
||||
def _calculate_total_weight(cls, logs) -> Decimal:
|
||||
"""
|
||||
Calculate total weight lifted from workout logs.
|
||||
|
||||
Volume = weight * reps for each set, summed across all logs.
|
||||
All weights are normalized to kg.
|
||||
"""
|
||||
total = Decimal('0')
|
||||
for log in logs:
|
||||
if log.weight is not None and log.repetitions is not None:
|
||||
weight_kg = cls._normalize_weight(log.weight, log.weight_unit_id)
|
||||
total += weight_kg * log.repetitions
|
||||
return total
|
||||
|
||||
@classmethod
|
||||
def _calculate_streaks(cls, workout_dates: list) -> tuple:
|
||||
"""
|
||||
Calculate current and longest workout streaks.
|
||||
|
||||
A streak is consecutive days with at least one workout.
|
||||
|
||||
Args:
|
||||
workout_dates: List of dates with workouts, sorted ascending
|
||||
|
||||
Returns:
|
||||
Tuple of (current_streak, longest_streak)
|
||||
"""
|
||||
if not workout_dates:
|
||||
return 0, 0
|
||||
|
||||
# Remove duplicates and sort
|
||||
unique_dates = sorted(set(workout_dates))
|
||||
|
||||
current_streak = 1
|
||||
longest_streak = 1
|
||||
streak = 1
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
for i in range(1, len(unique_dates)):
|
||||
if (unique_dates[i] - unique_dates[i - 1]).days == 1:
|
||||
streak += 1
|
||||
else:
|
||||
streak = 1
|
||||
|
||||
if streak > longest_streak:
|
||||
longest_streak = streak
|
||||
|
||||
# Check if current streak is active (includes today or yesterday)
|
||||
if unique_dates:
|
||||
last_workout = unique_dates[-1]
|
||||
days_since_last = (today - last_workout).days
|
||||
if days_since_last <= 1:
|
||||
current_streak = streak
|
||||
else:
|
||||
current_streak = 0
|
||||
|
||||
return current_streak, longest_streak
|
||||
|
||||
@classmethod
|
||||
def _calculate_workout_times(cls, sessions) -> tuple:
|
||||
"""
|
||||
Find earliest and latest workout start times.
|
||||
|
||||
Args:
|
||||
sessions: QuerySet of WorkoutSession
|
||||
|
||||
Returns:
|
||||
Tuple of (earliest_time, latest_time)
|
||||
"""
|
||||
times = [s.time_start for s in sessions if s.time_start is not None]
|
||||
if not times:
|
||||
return None, None
|
||||
return min(times), max(times)
|
||||
|
||||
@classmethod
|
||||
def _calculate_weekend_streak(cls, workout_dates: list) -> tuple:
|
||||
"""
|
||||
Calculate consecutive weekends with workouts on both Saturday and Sunday.
|
||||
|
||||
Args:
|
||||
workout_dates: List of workout dates
|
||||
|
||||
Returns:
|
||||
Tuple of (weekend_streak, last_complete_weekend_date)
|
||||
"""
|
||||
if not workout_dates:
|
||||
return 0, None
|
||||
|
||||
date_set = set(workout_dates)
|
||||
|
||||
# Find all complete weekends (both Sat and Sun)
|
||||
complete_weekends = []
|
||||
checked_saturdays = set()
|
||||
|
||||
for d in sorted(date_set):
|
||||
# Saturday = 5, Sunday = 6 in Python's weekday()
|
||||
if d.weekday() == 5: # Saturday
|
||||
sunday = d + datetime.timedelta(days=1)
|
||||
if sunday in date_set:
|
||||
complete_weekends.append(d)
|
||||
checked_saturdays.add(d)
|
||||
elif d.weekday() == 6: # Sunday
|
||||
saturday = d - datetime.timedelta(days=1)
|
||||
if saturday in date_set and saturday not in checked_saturdays:
|
||||
complete_weekends.append(saturday)
|
||||
checked_saturdays.add(saturday)
|
||||
|
||||
if not complete_weekends:
|
||||
return 0, None
|
||||
|
||||
# Sort by date
|
||||
complete_weekends = sorted(set(complete_weekends))
|
||||
|
||||
# Count consecutive weekends
|
||||
streak = 1
|
||||
max_streak = 1
|
||||
|
||||
for i in range(1, len(complete_weekends)):
|
||||
# Check if this is the next weekend (7 days apart)
|
||||
if (complete_weekends[i] - complete_weekends[i - 1]).days == 7:
|
||||
streak += 1
|
||||
if streak > max_streak:
|
||||
max_streak = streak
|
||||
else:
|
||||
streak = 1
|
||||
|
||||
# Determine current streak
|
||||
today = datetime.date.today()
|
||||
last_complete = complete_weekends[-1]
|
||||
|
||||
# Find the most recent Saturday
|
||||
days_since_saturday = (today.weekday() - 5) % 7
|
||||
last_saturday = today - datetime.timedelta(days=days_since_saturday)
|
||||
|
||||
# Current streak is valid if last complete weekend was within 2 weeks
|
||||
days_since_last_complete = (last_saturday - last_complete).days
|
||||
if days_since_last_complete <= 7:
|
||||
current_streak = streak
|
||||
else:
|
||||
current_streak = 0
|
||||
|
||||
return current_streak, last_complete
|
||||
|
||||
@classmethod
|
||||
def _update_weekend_streak_incremental(cls, stats: UserStatistics, workout_date: datetime.date):
|
||||
"""
|
||||
Incrementally update weekend streak when a workout is logged.
|
||||
|
||||
Args:
|
||||
stats: The UserStatistics to update
|
||||
workout_date: The date of the workout
|
||||
"""
|
||||
# Only process weekend days
|
||||
weekday = workout_date.weekday()
|
||||
if weekday not in (5, 6): # Not Saturday or Sunday
|
||||
return
|
||||
|
||||
# Determine the Saturday of this weekend
|
||||
if weekday == 5: # Saturday
|
||||
saturday = workout_date
|
||||
else: # Sunday
|
||||
saturday = workout_date - datetime.timedelta(days=1)
|
||||
|
||||
sunday = saturday + datetime.timedelta(days=1)
|
||||
|
||||
# Check if both days have workouts
|
||||
has_saturday = WorkoutSession.objects.filter(
|
||||
user=stats.user, date=saturday
|
||||
).exists()
|
||||
has_sunday = WorkoutSession.objects.filter(
|
||||
user=stats.user, date=sunday
|
||||
).exists()
|
||||
|
||||
if has_saturday and has_sunday:
|
||||
# This weekend is complete
|
||||
if stats.last_complete_weekend_date:
|
||||
days_since_last = (saturday - stats.last_complete_weekend_date).days
|
||||
if days_since_last == 7:
|
||||
# Consecutive weekend
|
||||
stats.weekend_workout_streak += 1
|
||||
elif days_since_last > 7:
|
||||
# Gap - reset streak
|
||||
stats.weekend_workout_streak = 1
|
||||
# If days_since_last < 7, it's the same weekend - no change
|
||||
else:
|
||||
# First complete weekend
|
||||
stats.weekend_workout_streak = 1
|
||||
|
||||
stats.last_complete_weekend_date = saturday
|
||||
|
||||
@classmethod
|
||||
def _check_jan_1_workout(cls, workout_dates: list) -> bool:
|
||||
"""
|
||||
Check if user has ever worked out on January 1st.
|
||||
|
||||
Args:
|
||||
workout_dates: List of workout dates
|
||||
|
||||
Returns:
|
||||
True if user has worked out on any January 1st
|
||||
"""
|
||||
return any(d.month == 1 and d.day == 1 for d in workout_dates)
|
||||
|
||||
@classmethod
|
||||
def _calculate_last_inactive_date(cls, workout_dates: list) -> Optional[datetime.date]:
|
||||
"""
|
||||
Calculate the last inactive date (for Phoenix trophy).
|
||||
|
||||
The last inactive date is the last workout date before a gap of 30+ days.
|
||||
|
||||
Args:
|
||||
workout_dates: List of workout dates, sorted ascending
|
||||
|
||||
Returns:
|
||||
The last inactive date, or None if no 30+ day gap exists
|
||||
"""
|
||||
if not workout_dates:
|
||||
return None
|
||||
|
||||
unique_dates = sorted(set(workout_dates))
|
||||
last_inactive = None
|
||||
|
||||
for i in range(1, len(unique_dates)):
|
||||
gap = (unique_dates[i] - unique_dates[i - 1]).days
|
||||
if gap >= 30:
|
||||
last_inactive = unique_dates[i - 1]
|
||||
|
||||
return last_inactive
|
||||
315
wger/trophies/services/trophy.py
Normal file
315
wger/trophies/services/trophy.py
Normal file
@@ -0,0 +1,315 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Standard Library
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import (
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
)
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
# wger
|
||||
from wger.trophies.checkers.registry import CheckerRegistry
|
||||
from wger.trophies.models import (
|
||||
Trophy,
|
||||
UserTrophy,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Trophy settings from WGER_SETTINGS (defined in settings_global.py)
|
||||
TROPHIES_ENABLED = settings.WGER_SETTINGS['TROPHIES_ENABLED']
|
||||
TROPHIES_INACTIVE_USER_DAYS = settings.WGER_SETTINGS['TROPHIES_INACTIVE_USER_DAYS']
|
||||
|
||||
|
||||
class TrophyService:
|
||||
"""
|
||||
Service class for trophy evaluation and management.
|
||||
|
||||
This service handles:
|
||||
- Evaluating whether users have earned trophies
|
||||
- Awarding trophies to users
|
||||
- Retrieving trophy progress and status
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def evaluate_all_trophies(cls, user: User) -> List[UserTrophy]:
|
||||
"""
|
||||
Evaluate all unearned trophies for a user.
|
||||
|
||||
Checks each active trophy the user hasn't earned yet using the
|
||||
appropriate checker class. Awards trophies where criteria are met.
|
||||
|
||||
Args:
|
||||
user: The user to evaluate trophies for
|
||||
|
||||
Returns:
|
||||
List of newly awarded UserTrophy instances
|
||||
"""
|
||||
if cls.should_skip_user(user):
|
||||
return []
|
||||
|
||||
# Get all active trophies the user hasn't earned
|
||||
earned_trophy_ids = UserTrophy.objects.filter(user=user).values_list('trophy_id', flat=True)
|
||||
unevaluated_trophies = Trophy.objects.filter(is_active=True).exclude(id__in=earned_trophy_ids)
|
||||
|
||||
awarded = []
|
||||
for trophy in unevaluated_trophies:
|
||||
user_trophy = cls.evaluate_trophy(user, trophy)
|
||||
if user_trophy:
|
||||
awarded.append(user_trophy)
|
||||
|
||||
return awarded
|
||||
|
||||
@classmethod
|
||||
def evaluate_trophy(cls, user: User, trophy: Trophy) -> Optional[UserTrophy]:
|
||||
"""
|
||||
Evaluate a single trophy for a user.
|
||||
|
||||
Creates a checker instance and checks if the user has met the
|
||||
trophy criteria. Awards the trophy if earned.
|
||||
|
||||
Args:
|
||||
user: The user to evaluate the trophy for
|
||||
trophy: The trophy to evaluate
|
||||
|
||||
Returns:
|
||||
UserTrophy if earned, None otherwise
|
||||
"""
|
||||
if not trophy.is_active:
|
||||
return None
|
||||
|
||||
# Check if already earned
|
||||
if UserTrophy.objects.filter(user=user, trophy=trophy).exists():
|
||||
return None
|
||||
|
||||
# Get the checker for this trophy
|
||||
checker = CheckerRegistry.create_checker(user, trophy)
|
||||
if checker is None:
|
||||
logger.warning(f'No checker found for trophy: {trophy.name}')
|
||||
return None
|
||||
|
||||
try:
|
||||
if checker.check():
|
||||
return cls.award_trophy(user, trophy, progress=100.0)
|
||||
except Exception as e:
|
||||
logger.error(f'Error checking trophy {trophy.name} for user {user.id}: {e}', exc_info=True)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def award_trophy(cls, user: User, trophy: Trophy, progress: float = 100.0) -> UserTrophy:
|
||||
"""
|
||||
Award a trophy to a user.
|
||||
|
||||
Creates a UserTrophy record marking the trophy as earned.
|
||||
|
||||
Args:
|
||||
user: The user to award the trophy to
|
||||
trophy: The trophy to award
|
||||
progress: The progress value (default 100 for earned)
|
||||
|
||||
Returns:
|
||||
The created UserTrophy instance
|
||||
"""
|
||||
user_trophy, created = UserTrophy.objects.get_or_create(
|
||||
user=user,
|
||||
trophy=trophy,
|
||||
defaults={'progress': progress},
|
||||
)
|
||||
|
||||
if created:
|
||||
logger.info(f'Awarded trophy "{trophy.name}" to user {user.username}')
|
||||
|
||||
return user_trophy
|
||||
|
||||
@classmethod
|
||||
def get_user_trophies(cls, user: User) -> List[UserTrophy]:
|
||||
"""
|
||||
Get all earned trophies for a user.
|
||||
|
||||
Args:
|
||||
user: The user to get trophies for
|
||||
|
||||
Returns:
|
||||
List of UserTrophy instances
|
||||
"""
|
||||
return list(
|
||||
UserTrophy.objects.filter(user=user)
|
||||
.select_related('trophy')
|
||||
.order_by('-earned_at')
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_all_trophy_progress(cls, user: User, include_hidden: bool = False) -> List[Dict]:
|
||||
"""
|
||||
Get all trophies with progress information for a user.
|
||||
|
||||
Returns both earned and unearned trophies with their current progress.
|
||||
For progressive trophies, calculates progress on-the-fly.
|
||||
|
||||
Args:
|
||||
user: The user to get trophy progress for
|
||||
include_hidden: If True, include hidden trophies even if not earned
|
||||
|
||||
Returns:
|
||||
List of dicts with trophy info and progress
|
||||
"""
|
||||
result = []
|
||||
|
||||
# Get all active trophies
|
||||
trophies = Trophy.objects.filter(is_active=True).order_by('order', 'name')
|
||||
|
||||
# Get user's earned trophies
|
||||
earned = {
|
||||
ut.trophy_id: ut
|
||||
for ut in UserTrophy.objects.filter(user=user).select_related('trophy')
|
||||
}
|
||||
|
||||
for trophy in trophies:
|
||||
user_trophy = earned.get(trophy.id)
|
||||
is_earned = user_trophy is not None
|
||||
|
||||
# Skip hidden trophies unless earned or explicitly included
|
||||
if trophy.is_hidden and not is_earned and not include_hidden:
|
||||
continue
|
||||
|
||||
progress_data = {
|
||||
'trophy': trophy,
|
||||
'is_earned': is_earned,
|
||||
'earned_at': user_trophy.earned_at if is_earned else None,
|
||||
'progress': 100.0 if is_earned else 0.0,
|
||||
'current_value': None,
|
||||
'target_value': None,
|
||||
'progress_display': None,
|
||||
}
|
||||
|
||||
# Calculate progress for progressive trophies that aren't earned
|
||||
if trophy.is_progressive and not is_earned:
|
||||
checker = CheckerRegistry.create_checker(user, trophy)
|
||||
if checker:
|
||||
try:
|
||||
progress_data['progress'] = checker.get_progress()
|
||||
progress_data['current_value'] = checker.get_current_value()
|
||||
progress_data['target_value'] = checker.get_target_value()
|
||||
|
||||
# Create display string (e.g., "5000/100000 kg")
|
||||
current = progress_data['current_value']
|
||||
target = progress_data['target_value']
|
||||
if current is not None and target is not None:
|
||||
progress_data['progress_display'] = f'{current}/{target}'
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting progress for trophy {trophy.name}: {e}')
|
||||
|
||||
result.append(progress_data)
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def should_skip_user(cls, user: User) -> bool:
|
||||
"""
|
||||
Check if a user should be skipped for trophy evaluation.
|
||||
|
||||
Users are skipped if:
|
||||
- The trophy system is globally disabled (WGER_SETTINGS['TROPHIES_ENABLED'])
|
||||
- They have disabled trophies in their profile (userprofile.trophies_enabled)
|
||||
- They haven't logged in for more than TROPHIES_INACTIVE_USER_DAYS
|
||||
|
||||
Args:
|
||||
user: The user to check
|
||||
|
||||
Returns:
|
||||
True if user should be skipped
|
||||
"""
|
||||
# Check if trophy system is globally disabled
|
||||
if not TROPHIES_ENABLED:
|
||||
return True
|
||||
|
||||
# Check if user has disabled trophies (if profile has this field)
|
||||
if hasattr(user, 'userprofile'):
|
||||
profile = user.userprofile
|
||||
if hasattr(profile, 'trophies_enabled') and not profile.trophies_enabled:
|
||||
return True
|
||||
|
||||
# Check for inactivity
|
||||
if user.last_login:
|
||||
inactive_threshold = timezone.now() - timedelta(days=TROPHIES_INACTIVE_USER_DAYS)
|
||||
if user.last_login < inactive_threshold:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def reevaluate_trophies(
|
||||
cls,
|
||||
trophy_ids: Optional[List[int]] = None,
|
||||
user_ids: Optional[List[int]] = None,
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Re-evaluate trophies for users (admin function).
|
||||
|
||||
Can be used when trophy criteria change or to fix data inconsistencies.
|
||||
This will check if any users now qualify for trophies they didn't before.
|
||||
|
||||
Args:
|
||||
trophy_ids: List of trophy IDs to re-evaluate (None = all)
|
||||
user_ids: List of user IDs to re-evaluate (None = all active)
|
||||
|
||||
Returns:
|
||||
Dict with counts: {'users_checked': N, 'trophies_awarded': M}
|
||||
"""
|
||||
# Get trophies to evaluate
|
||||
trophy_qs = Trophy.objects.filter(is_active=True)
|
||||
if trophy_ids:
|
||||
trophy_qs = trophy_qs.filter(id__in=trophy_ids)
|
||||
trophies = list(trophy_qs)
|
||||
|
||||
# Get users to evaluate
|
||||
if user_ids:
|
||||
users = User.objects.filter(id__in=user_ids)
|
||||
else:
|
||||
# Get active users only
|
||||
inactive_threshold = timezone.now() - timedelta(days=TROPHIES_INACTIVE_USER_DAYS)
|
||||
users = User.objects.filter(last_login__gte=inactive_threshold)
|
||||
|
||||
users_checked = 0
|
||||
trophies_awarded = 0
|
||||
|
||||
for user in users:
|
||||
if cls.should_skip_user(user):
|
||||
continue
|
||||
|
||||
users_checked += 1
|
||||
|
||||
for trophy in trophies:
|
||||
# Don't skip already earned - this is a re-evaluation
|
||||
user_trophy = cls.evaluate_trophy(user, trophy)
|
||||
if user_trophy:
|
||||
trophies_awarded += 1
|
||||
|
||||
return {
|
||||
'users_checked': users_checked,
|
||||
'trophies_awarded': trophies_awarded,
|
||||
}
|
||||
158
wger/trophies/signals.py
Normal file
158
wger/trophies/signals.py
Normal file
@@ -0,0 +1,158 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Signal handlers for the trophies app.
|
||||
|
||||
These signals trigger statistics updates and trophy evaluations
|
||||
when workouts are logged, edited, or deleted.
|
||||
"""
|
||||
|
||||
# Standard Library
|
||||
import logging
|
||||
|
||||
# Django
|
||||
from django.db.models.signals import (
|
||||
post_delete,
|
||||
post_save,
|
||||
)
|
||||
from django.dispatch import receiver
|
||||
|
||||
# wger
|
||||
from wger.manager.models import (
|
||||
WorkoutLog,
|
||||
WorkoutSession,
|
||||
)
|
||||
from wger.trophies.services import UserStatisticsService
|
||||
from wger.trophies.tasks import evaluate_user_trophies_task
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _trigger_trophy_evaluation(user_id: int):
|
||||
"""
|
||||
Trigger async trophy evaluation for a user.
|
||||
|
||||
Uses Celery if available, otherwise evaluates synchronously.
|
||||
"""
|
||||
try:
|
||||
evaluate_user_trophies_task.delay(user_id)
|
||||
except Exception:
|
||||
# Celery not available - evaluate synchronously
|
||||
from wger.trophies.services import TrophyService
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
TrophyService.evaluate_all_trophies(user)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f'Error evaluating trophies for user {user_id}: {e}')
|
||||
|
||||
|
||||
@receiver(post_save, sender=WorkoutLog)
|
||||
def workout_log_saved(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Handle WorkoutLog save events.
|
||||
|
||||
Updates user statistics when a new workout log is created.
|
||||
For edits, triggers a full recalculation to ensure accuracy.
|
||||
Then triggers trophy evaluation.
|
||||
"""
|
||||
if not instance.user_id:
|
||||
return
|
||||
|
||||
try:
|
||||
if created:
|
||||
# New log - incremental update
|
||||
UserStatisticsService.increment_workout(
|
||||
user=instance.user,
|
||||
workout_log=instance,
|
||||
)
|
||||
else:
|
||||
# Edited log - full recalculation for accuracy
|
||||
UserStatisticsService.update_statistics(instance.user)
|
||||
|
||||
# Trigger trophy evaluation
|
||||
_trigger_trophy_evaluation(instance.user_id)
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating statistics for user {instance.user_id}: {e}', exc_info=True)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=WorkoutLog)
|
||||
def workout_log_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
Handle WorkoutLog delete events.
|
||||
|
||||
Triggers full statistics recalculation when a log is deleted.
|
||||
"""
|
||||
if not instance.user_id:
|
||||
return
|
||||
|
||||
try:
|
||||
UserStatisticsService.handle_workout_deletion(instance.user)
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating statistics after deletion for user {instance.user_id}: {e}', exc_info=True)
|
||||
|
||||
|
||||
@receiver(post_save, sender=WorkoutSession)
|
||||
def workout_session_saved(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Handle WorkoutSession save events.
|
||||
|
||||
Updates user statistics when a workout session is created or updated.
|
||||
This captures session-level data like start/end times.
|
||||
Then triggers trophy evaluation.
|
||||
"""
|
||||
if not instance.user_id:
|
||||
return
|
||||
|
||||
try:
|
||||
if created:
|
||||
# New session - incremental update for session data
|
||||
UserStatisticsService.increment_workout(
|
||||
user=instance.user,
|
||||
session=instance,
|
||||
)
|
||||
else:
|
||||
# Session updated (e.g., time_start changed) - update times
|
||||
UserStatisticsService.increment_workout(
|
||||
user=instance.user,
|
||||
session=instance,
|
||||
)
|
||||
|
||||
# Trigger trophy evaluation
|
||||
_trigger_trophy_evaluation(instance.user_id)
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating statistics for session {instance.id}: {e}', exc_info=True)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=WorkoutSession)
|
||||
def workout_session_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
Handle WorkoutSession delete events.
|
||||
|
||||
Triggers full statistics recalculation when a session is deleted.
|
||||
"""
|
||||
if not instance.user_id:
|
||||
return
|
||||
|
||||
try:
|
||||
UserStatisticsService.handle_workout_deletion(instance.user)
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating statistics after session deletion for user {instance.user_id}: {e}', exc_info=True)
|
||||
139
wger/trophies/tasks.py
Normal file
139
wger/trophies/tasks.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Celery tasks for trophy evaluation and statistics updates.
|
||||
"""
|
||||
|
||||
# Standard Library
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
# wger
|
||||
from wger.celery_configuration import app
|
||||
from wger.trophies.services import (
|
||||
TrophyService,
|
||||
UserStatisticsService,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Trophy settings from WGER_SETTINGS (defined in settings_global.py)
|
||||
TROPHIES_INACTIVE_USER_DAYS = settings.WGER_SETTINGS['TROPHIES_INACTIVE_USER_DAYS']
|
||||
|
||||
|
||||
@app.task
|
||||
def evaluate_user_trophies_task(user_id: int):
|
||||
"""
|
||||
Evaluate all trophies for a single user.
|
||||
|
||||
This task is typically called after a workout is logged.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user to evaluate
|
||||
"""
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
awarded = TrophyService.evaluate_all_trophies(user)
|
||||
if awarded:
|
||||
logger.info(f'Awarded {len(awarded)} trophies to user {user.username}')
|
||||
except User.DoesNotExist:
|
||||
logger.warning(f'User {user_id} not found for trophy evaluation')
|
||||
except Exception as e:
|
||||
logger.error(f'Error evaluating trophies for user {user_id}: {e}', exc_info=True)
|
||||
|
||||
|
||||
@app.task
|
||||
def evaluate_all_users_trophies_task():
|
||||
"""
|
||||
Evaluate trophies for all active users.
|
||||
|
||||
This task can be run periodically (e.g., daily) to catch any
|
||||
missed trophy awards or re-evaluate after criteria changes.
|
||||
|
||||
Only processes users who have logged in within TROPHIES_INACTIVE_USER_DAYS.
|
||||
"""
|
||||
inactive_threshold = timezone.now() - timedelta(days=TROPHIES_INACTIVE_USER_DAYS)
|
||||
users = User.objects.filter(last_login__gte=inactive_threshold)
|
||||
|
||||
total_awarded = 0
|
||||
users_processed = 0
|
||||
|
||||
for user in users.iterator():
|
||||
try:
|
||||
awarded = TrophyService.evaluate_all_trophies(user)
|
||||
if awarded:
|
||||
total_awarded += len(awarded)
|
||||
users_processed += 1
|
||||
except Exception as e:
|
||||
logger.error(f'Error evaluating trophies for user {user.id}: {e}', exc_info=True)
|
||||
|
||||
logger.info(
|
||||
f'Trophy evaluation complete: processed {users_processed} users, '
|
||||
f'awarded {total_awarded} trophies'
|
||||
)
|
||||
|
||||
|
||||
@app.task
|
||||
def update_user_statistics_task(user_id: int):
|
||||
"""
|
||||
Perform a full statistics recalculation for a user.
|
||||
|
||||
This task is useful for:
|
||||
- Initial statistics population
|
||||
- Recovery from data inconsistencies
|
||||
- After bulk data imports
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user to update
|
||||
"""
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
UserStatisticsService.update_statistics(user)
|
||||
logger.info(f'Updated statistics for user {user.username}')
|
||||
except User.DoesNotExist:
|
||||
logger.warning(f'User {user_id} not found for statistics update')
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating statistics for user {user_id}: {e}', exc_info=True)
|
||||
|
||||
|
||||
@app.task
|
||||
def recalculate_all_statistics_task():
|
||||
"""
|
||||
Recalculate statistics for all active users.
|
||||
|
||||
This task can be used for data recovery or after major changes.
|
||||
Only processes users who have logged in recently.
|
||||
"""
|
||||
inactive_threshold = timezone.now() - timedelta(days=TROPHIES_INACTIVE_USER_DAYS)
|
||||
users = User.objects.filter(last_login__gte=inactive_threshold)
|
||||
|
||||
users_processed = 0
|
||||
|
||||
for user in users.iterator():
|
||||
try:
|
||||
UserStatisticsService.update_statistics(user)
|
||||
users_processed += 1
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating statistics for user {user.id}: {e}', exc_info=True)
|
||||
|
||||
logger.info(f'Statistics recalculation complete: processed {users_processed} users')
|
||||
15
wger/trophies/tests/__init__.py
Normal file
15
wger/trophies/tests/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# This file is part of wger Workout Manager <https://github.com/wger-project>.
|
||||
# Copyright (C) 2013 - 2021 wger Team
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
389
wger/trophies/tests/test_api.py
Normal file
389
wger/trophies/tests/test_api.py
Normal file
@@ -0,0 +1,389 @@
|
||||
# This file is part of wger Workout Manager.
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
|
||||
# Standard Library
|
||||
from decimal import Decimal
|
||||
|
||||
# Django
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
|
||||
# Third Party
|
||||
from rest_framework import status
|
||||
|
||||
# wger
|
||||
from wger.core.tests.base_testcase import WgerTestCase
|
||||
from wger.manager.models import (
|
||||
WorkoutLog,
|
||||
WorkoutSession,
|
||||
)
|
||||
from wger.trophies.models import (
|
||||
Trophy,
|
||||
UserStatistics,
|
||||
UserTrophy,
|
||||
)
|
||||
|
||||
|
||||
class TrophyAPITestCase(WgerTestCase):
|
||||
"""
|
||||
Test the Trophy API endpoints
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
self.user_login()
|
||||
|
||||
# Create some test trophies
|
||||
self.trophy1 = Trophy.objects.create(
|
||||
name='Active Trophy 1',
|
||||
description='Test description',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 1},
|
||||
is_active=True,
|
||||
is_hidden=False,
|
||||
order=1,
|
||||
)
|
||||
self.trophy2 = Trophy.objects.create(
|
||||
name='Active Trophy 2',
|
||||
description='Another trophy',
|
||||
trophy_type=Trophy.TYPE_VOLUME,
|
||||
checker_class='volume',
|
||||
checker_params={'kg': 5000},
|
||||
is_active=True,
|
||||
is_progressive=True,
|
||||
order=2,
|
||||
)
|
||||
self.inactive_trophy = Trophy.objects.create(
|
||||
name='Inactive Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 10},
|
||||
is_active=False,
|
||||
)
|
||||
|
||||
def test_list_trophies_authenticated(self):
|
||||
"""Test listing trophies as authenticated user"""
|
||||
response = self.client.get(reverse('trophy-list'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertIn('results', data)
|
||||
|
||||
def test_list_trophies_unauthenticated(self):
|
||||
"""Test listing trophies allows unauthenticated access for non-hidden trophies"""
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse('trophy-list'))
|
||||
|
||||
# Anonymous users can see non-hidden trophies
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_list_only_active_trophies(self):
|
||||
"""Test only active trophies are returned"""
|
||||
response = self.client.get(reverse('trophy-list'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
trophy_ids = [t['id'] for t in data['results']]
|
||||
self.assertIn(self.trophy1.id, trophy_ids)
|
||||
self.assertIn(self.trophy2.id, trophy_ids)
|
||||
self.assertNotIn(self.inactive_trophy.id, trophy_ids)
|
||||
|
||||
def test_trophy_detail(self):
|
||||
"""Test getting trophy detail"""
|
||||
response = self.client.get(reverse('trophy-detail', kwargs={'pk': self.trophy1.pk}))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
self.assertEqual(data['id'], self.trophy1.id)
|
||||
self.assertEqual(data['name'], 'Active Trophy 1')
|
||||
self.assertEqual(data['description'], 'Test description')
|
||||
self.assertEqual(data['trophy_type'], Trophy.TYPE_COUNT)
|
||||
self.assertFalse(data['is_progressive'])
|
||||
|
||||
def test_trophy_serialization_fields(self):
|
||||
"""Test trophy serialization includes all required fields"""
|
||||
response = self.client.get(reverse('trophy-detail', kwargs={'pk': self.trophy1.pk}))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
# Check all expected fields are present
|
||||
expected_fields = ['id', 'name', 'description', 'trophy_type', 'is_hidden', 'is_progressive']
|
||||
for field in expected_fields:
|
||||
self.assertIn(field, data)
|
||||
|
||||
def test_trophy_ordering(self):
|
||||
"""Test trophies are ordered correctly"""
|
||||
# Delete migration trophies and keep only test trophies
|
||||
Trophy.objects.exclude(
|
||||
id__in=[self.trophy1.id, self.trophy2.id, self.inactive_trophy.id]
|
||||
).delete()
|
||||
|
||||
response = self.client.get(reverse('trophy-list'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
trophy_names = [t['name'] for t in data['results']]
|
||||
# Should be ordered by order field then name
|
||||
self.assertEqual(trophy_names[0], 'Active Trophy 1') # order=1
|
||||
self.assertEqual(trophy_names[1], 'Active Trophy 2') # order=2
|
||||
|
||||
def test_trophy_read_only(self):
|
||||
"""Test trophies endpoint is read-only"""
|
||||
# Try to create a trophy via API
|
||||
response = self.client.post(
|
||||
reverse('trophy-list'),
|
||||
data={
|
||||
'name': 'New Trophy',
|
||||
'trophy_type': Trophy.TYPE_COUNT,
|
||||
'checker_class': 'count_based',
|
||||
},
|
||||
)
|
||||
|
||||
# Should not be allowed
|
||||
self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_405_METHOD_NOT_ALLOWED])
|
||||
|
||||
|
||||
class UserTrophyAPITestCase(WgerTestCase):
|
||||
"""
|
||||
Test the UserTrophy API endpoints
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
self.other_user = User.objects.get(username='test')
|
||||
self.user_login()
|
||||
|
||||
# Delete workout data, migration trophies, and existing data to ensure clean state
|
||||
WorkoutLog.objects.filter(user=self.user).delete()
|
||||
WorkoutSession.objects.filter(user=self.user).delete()
|
||||
Trophy.objects.all().delete()
|
||||
UserTrophy.objects.all().delete()
|
||||
UserStatistics.objects.filter(user=self.user).delete()
|
||||
|
||||
# Create trophies
|
||||
self.trophy1 = Trophy.objects.create(
|
||||
name='Trophy 1',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 1},
|
||||
is_active=True,
|
||||
)
|
||||
self.trophy2 = Trophy.objects.create(
|
||||
name='Trophy 2',
|
||||
trophy_type=Trophy.TYPE_VOLUME,
|
||||
checker_class='volume',
|
||||
checker_params={'kg': 5000},
|
||||
is_active=True,
|
||||
is_progressive=True,
|
||||
)
|
||||
self.hidden_trophy = Trophy.objects.create(
|
||||
name='Hidden Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 10},
|
||||
is_active=True,
|
||||
is_hidden=True,
|
||||
)
|
||||
|
||||
# Award trophy1 to current user
|
||||
self.user_trophy = UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=self.trophy1,
|
||||
progress=100.0,
|
||||
)
|
||||
|
||||
# Award trophy2 to other user
|
||||
UserTrophy.objects.create(
|
||||
user=self.other_user,
|
||||
trophy=self.trophy2,
|
||||
progress=100.0,
|
||||
)
|
||||
|
||||
# Create statistics for progress calculation
|
||||
self.stats, _ = UserStatistics.objects.get_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
'total_workouts': 1,
|
||||
'total_weight_lifted': Decimal('2500'),
|
||||
}
|
||||
)
|
||||
|
||||
def test_list_user_trophies_authenticated(self):
|
||||
"""Test listing user's earned trophies"""
|
||||
response = self.client.get(reverse('user-trophy-list'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertIn('results', data)
|
||||
|
||||
def test_list_user_trophies_unauthenticated(self):
|
||||
"""Test listing user trophies requires authentication"""
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse('user-trophy-list'))
|
||||
|
||||
self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
|
||||
|
||||
def test_user_only_sees_own_trophies(self):
|
||||
"""Test users only see their own earned trophies"""
|
||||
response = self.client.get(reverse('user-trophy-list'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
# Should only see trophy1 (awarded to self.user)
|
||||
self.assertEqual(len(data['results']), 1)
|
||||
self.assertEqual(data['results'][0]['trophy']['id'], self.trophy1.id)
|
||||
|
||||
def test_user_trophy_serialization(self):
|
||||
"""Test user trophy includes earned_at and progress"""
|
||||
response = self.client.get(reverse('user-trophy-list'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
user_trophy_data = data['results'][0]
|
||||
self.assertIn('earned_at', user_trophy_data)
|
||||
self.assertIn('progress', user_trophy_data)
|
||||
self.assertEqual(user_trophy_data['progress'], 100.0)
|
||||
|
||||
def test_trophy_progress_endpoint(self):
|
||||
"""Test the trophy progress endpoint"""
|
||||
response = self.client.get(reverse('trophy-progress'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
# Should return progress for all active trophies
|
||||
self.assertIsInstance(data, list)
|
||||
self.assertGreater(len(data), 0)
|
||||
|
||||
def test_trophy_progress_includes_unearned(self):
|
||||
"""Test progress endpoint includes unearned trophies"""
|
||||
response = self.client.get(reverse('trophy-progress'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
trophy_ids = [t['trophy']['id'] for t in data]
|
||||
|
||||
# Should include trophy1 (earned) and trophy2 (not earned)
|
||||
self.assertIn(self.trophy1.id, trophy_ids)
|
||||
self.assertIn(self.trophy2.id, trophy_ids)
|
||||
|
||||
def test_trophy_progress_calculations(self):
|
||||
"""Test progress calculations are correct"""
|
||||
response = self.client.get(reverse('trophy-progress'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
# Find trophy2 (progressive volume trophy)
|
||||
trophy2_data = next((t for t in data if t['trophy']['id'] == self.trophy2.id), None)
|
||||
self.assertIsNotNone(trophy2_data)
|
||||
|
||||
# User has lifted 2500kg, trophy requires 5000kg = 50%
|
||||
self.assertEqual(trophy2_data['progress'], 50.0)
|
||||
self.assertFalse(trophy2_data['is_earned'])
|
||||
|
||||
def test_trophy_progress_earned_status(self):
|
||||
"""Test earned trophies show is_earned=True"""
|
||||
response = self.client.get(reverse('trophy-progress'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
# Find trophy1 (earned)
|
||||
trophy1_data = next((t for t in data if t['trophy']['id'] == self.trophy1.id), None)
|
||||
self.assertIsNotNone(trophy1_data)
|
||||
|
||||
self.assertTrue(trophy1_data['is_earned'])
|
||||
self.assertEqual(trophy1_data['progress'], 100.0)
|
||||
self.assertIsNotNone(trophy1_data['earned_at'])
|
||||
|
||||
def test_hidden_trophy_not_in_progress(self):
|
||||
"""Test hidden trophies not shown in progress unless earned"""
|
||||
# Temporarily make user non-staff to test hidden trophy filtering
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
|
||||
response = self.client.get(reverse('trophy-progress'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
trophy_ids = [t['trophy']['id'] for t in data]
|
||||
|
||||
# Hidden trophy should not be in the list (not earned) for non-staff users
|
||||
self.assertNotIn(self.hidden_trophy.id, trophy_ids)
|
||||
|
||||
# Restore staff status
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
|
||||
def test_hidden_trophy_shown_when_earned(self):
|
||||
"""Test hidden trophies appear in progress once earned"""
|
||||
# Award hidden trophy to user
|
||||
UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=self.hidden_trophy,
|
||||
progress=100.0,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse('trophy-progress'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
trophy_ids = [t['trophy']['id'] for t in data]
|
||||
|
||||
# Hidden trophy should now be visible
|
||||
self.assertIn(self.hidden_trophy.id, trophy_ids)
|
||||
|
||||
def test_user_trophy_read_only(self):
|
||||
"""Test user trophy endpoints are read-only"""
|
||||
# Try to create a user trophy via API
|
||||
response = self.client.post(
|
||||
reverse('user-trophy-list'),
|
||||
data={
|
||||
'trophy': self.trophy2.id,
|
||||
'progress': 100.0,
|
||||
},
|
||||
)
|
||||
|
||||
# Should not be allowed
|
||||
self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_405_METHOD_NOT_ALLOWED])
|
||||
|
||||
def test_trophy_progress_display_format(self):
|
||||
"""Test progress display includes current and target values"""
|
||||
response = self.client.get(reverse('trophy-progress'))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
|
||||
# Find trophy2 (progressive trophy)
|
||||
trophy2_data = next((t for t in data if t['trophy']['id'] == self.trophy2.id), None)
|
||||
|
||||
# Should have current_value and target_value
|
||||
self.assertIn('current_value', trophy2_data)
|
||||
self.assertIn('target_value', trophy2_data)
|
||||
# Values are returned as strings from serialization
|
||||
self.assertEqual(trophy2_data['current_value'], '2500.00')
|
||||
self.assertEqual(trophy2_data['target_value'], '5000.0')
|
||||
487
wger/trophies/tests/test_checkers.py
Normal file
487
wger/trophies/tests/test_checkers.py
Normal file
@@ -0,0 +1,487 @@
|
||||
# This file is part of wger Workout Manager.
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
|
||||
# Standard Library
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
# Django
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
# wger
|
||||
from wger.core.tests.base_testcase import WgerTestCase
|
||||
from wger.trophies.checkers.count_based import CountBasedChecker
|
||||
from wger.trophies.checkers.date_based import DateBasedChecker
|
||||
from wger.trophies.checkers.inactivity_return import InactivityReturnChecker
|
||||
from wger.trophies.checkers.streak import StreakChecker
|
||||
from wger.trophies.checkers.time_based import TimeBasedChecker
|
||||
from wger.trophies.checkers.volume import VolumeChecker
|
||||
from wger.trophies.checkers.weekend_warrior import WeekendWarriorChecker
|
||||
from wger.trophies.models import (
|
||||
Trophy,
|
||||
UserStatistics,
|
||||
)
|
||||
|
||||
|
||||
class CountBasedCheckerTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the CountBasedChecker
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
self.trophy = Trophy.objects.create(
|
||||
name='Beginner',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 10},
|
||||
)
|
||||
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
def test_check_not_achieved(self):
|
||||
"""Test check returns False when count not reached"""
|
||||
self.stats.total_workouts = 5
|
||||
self.stats.save()
|
||||
|
||||
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
|
||||
self.assertFalse(checker.check())
|
||||
|
||||
def test_check_achieved(self):
|
||||
"""Test check returns True when count reached"""
|
||||
self.stats.total_workouts = 10
|
||||
self.stats.save()
|
||||
|
||||
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_check_exceeded(self):
|
||||
"""Test check returns True when count exceeded"""
|
||||
self.stats.total_workouts = 15
|
||||
self.stats.save()
|
||||
|
||||
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_progress_calculation(self):
|
||||
"""Test progress calculation"""
|
||||
self.stats.total_workouts = 5
|
||||
self.stats.save()
|
||||
|
||||
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
|
||||
self.assertEqual(checker.get_progress(), 50.0)
|
||||
|
||||
def test_progress_capped_at_100(self):
|
||||
"""Test progress is capped at 100%"""
|
||||
self.stats.total_workouts = 15
|
||||
self.stats.save()
|
||||
|
||||
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
|
||||
self.assertEqual(checker.get_progress(), 100.0)
|
||||
|
||||
def test_get_current_value(self):
|
||||
"""Test getting current workout count"""
|
||||
self.stats.total_workouts = 7
|
||||
self.stats.save()
|
||||
|
||||
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
|
||||
self.assertEqual(checker.get_current_value(), 7)
|
||||
|
||||
def test_get_target_value(self):
|
||||
"""Test getting target workout count"""
|
||||
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
|
||||
self.assertEqual(checker.get_target_value(), 10)
|
||||
|
||||
|
||||
class StreakCheckerTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the StreakChecker
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
self.trophy = Trophy.objects.create(
|
||||
name='Unstoppable',
|
||||
trophy_type=Trophy.TYPE_SEQUENCE,
|
||||
checker_class='streak',
|
||||
checker_params={'days': 30},
|
||||
)
|
||||
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
def test_check_not_achieved(self):
|
||||
"""Test check returns False when streak not reached"""
|
||||
self.stats.current_streak = 15
|
||||
self.stats.save()
|
||||
|
||||
checker = StreakChecker(self.user, self.trophy, {'days': 30})
|
||||
self.assertFalse(checker.check())
|
||||
|
||||
def test_check_achieved(self):
|
||||
"""Test check returns True when streak reached"""
|
||||
self.stats.current_streak = 30
|
||||
self.stats.save()
|
||||
|
||||
checker = StreakChecker(self.user, self.trophy, {'days': 30})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_check_exceeded(self):
|
||||
"""Test check returns True when streak exceeded"""
|
||||
self.stats.current_streak = 45
|
||||
self.stats.save()
|
||||
|
||||
checker = StreakChecker(self.user, self.trophy, {'days': 30})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_progress_calculation(self):
|
||||
"""Test progress calculation"""
|
||||
self.stats.current_streak = 15
|
||||
self.stats.save()
|
||||
|
||||
checker = StreakChecker(self.user, self.trophy, {'days': 30})
|
||||
self.assertEqual(checker.get_progress(), 50.0)
|
||||
|
||||
def test_get_current_value(self):
|
||||
"""Test getting current streak"""
|
||||
self.stats.current_streak = 20
|
||||
self.stats.save()
|
||||
|
||||
checker = StreakChecker(self.user, self.trophy, {'days': 30})
|
||||
self.assertEqual(checker.get_current_value(), 20)
|
||||
|
||||
def test_get_target_value(self):
|
||||
"""Test getting target streak"""
|
||||
checker = StreakChecker(self.user, self.trophy, {'days': 30})
|
||||
self.assertEqual(checker.get_target_value(), 30)
|
||||
|
||||
|
||||
class WeekendWarriorCheckerTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the WeekendWarriorChecker
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
self.trophy = Trophy.objects.create(
|
||||
name='Weekend Warrior',
|
||||
trophy_type=Trophy.TYPE_SEQUENCE,
|
||||
checker_class='weekend_warrior',
|
||||
checker_params={'weekends': 4},
|
||||
)
|
||||
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
def test_check_not_achieved(self):
|
||||
"""Test check returns False when weekend streak not reached"""
|
||||
self.stats.weekend_workout_streak = 2
|
||||
self.stats.save()
|
||||
|
||||
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
|
||||
self.assertFalse(checker.check())
|
||||
|
||||
def test_check_achieved(self):
|
||||
"""Test check returns True when weekend streak reached"""
|
||||
self.stats.weekend_workout_streak = 4
|
||||
self.stats.save()
|
||||
|
||||
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_check_exceeded(self):
|
||||
"""Test check returns True when weekend streak exceeded"""
|
||||
self.stats.weekend_workout_streak = 6
|
||||
self.stats.save()
|
||||
|
||||
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_progress_calculation(self):
|
||||
"""Test progress calculation"""
|
||||
self.stats.weekend_workout_streak = 2
|
||||
self.stats.save()
|
||||
|
||||
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
|
||||
self.assertEqual(checker.get_progress(), 50.0)
|
||||
|
||||
def test_get_current_value(self):
|
||||
"""Test getting current weekend streak"""
|
||||
self.stats.weekend_workout_streak = 3
|
||||
self.stats.save()
|
||||
|
||||
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
|
||||
self.assertEqual(checker.get_current_value(), 3)
|
||||
|
||||
def test_get_target_value(self):
|
||||
"""Test getting target weekend streak"""
|
||||
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
|
||||
self.assertEqual(checker.get_target_value(), 4)
|
||||
|
||||
|
||||
class VolumeCheckerTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the VolumeChecker
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
self.trophy = Trophy.objects.create(
|
||||
name='Lifter',
|
||||
trophy_type=Trophy.TYPE_VOLUME,
|
||||
checker_class='volume',
|
||||
checker_params={'kg': 5000},
|
||||
)
|
||||
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
def test_check_not_achieved(self):
|
||||
"""Test check returns False when volume not reached"""
|
||||
self.stats.total_weight_lifted = Decimal('2500.00')
|
||||
self.stats.save()
|
||||
|
||||
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
|
||||
self.assertFalse(checker.check())
|
||||
|
||||
def test_check_achieved(self):
|
||||
"""Test check returns True when volume reached"""
|
||||
self.stats.total_weight_lifted = Decimal('5000.00')
|
||||
self.stats.save()
|
||||
|
||||
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_check_exceeded(self):
|
||||
"""Test check returns True when volume exceeded"""
|
||||
self.stats.total_weight_lifted = Decimal('7500.00')
|
||||
self.stats.save()
|
||||
|
||||
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_progress_calculation(self):
|
||||
"""Test progress calculation"""
|
||||
self.stats.total_weight_lifted = Decimal('2500.00')
|
||||
self.stats.save()
|
||||
|
||||
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
|
||||
self.assertEqual(checker.get_progress(), 50.0)
|
||||
|
||||
def test_progress_with_decimals(self):
|
||||
"""Test progress calculation with decimal weights"""
|
||||
self.stats.total_weight_lifted = Decimal('1234.56')
|
||||
self.stats.save()
|
||||
|
||||
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
|
||||
progress = checker.get_progress()
|
||||
self.assertAlmostEqual(progress, 24.69, places=2)
|
||||
|
||||
def test_get_current_value(self):
|
||||
"""Test getting current weight lifted"""
|
||||
self.stats.total_weight_lifted = Decimal('3000.00')
|
||||
self.stats.save()
|
||||
|
||||
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
|
||||
self.assertEqual(checker.get_current_value(), 3000)
|
||||
|
||||
def test_get_target_value(self):
|
||||
"""Test getting target weight"""
|
||||
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
|
||||
self.assertEqual(checker.get_target_value(), 5000)
|
||||
|
||||
|
||||
class TimeBasedCheckerTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the TimeBasedChecker
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
def test_early_bird_achieved(self):
|
||||
"""Test Early Bird trophy (before 6:00 AM)"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Early Bird',
|
||||
trophy_type=Trophy.TYPE_TIME,
|
||||
checker_class='time_based',
|
||||
checker_params={'before': '06:00'},
|
||||
)
|
||||
|
||||
self.stats.earliest_workout_time = datetime.time(5, 30)
|
||||
self.stats.save()
|
||||
|
||||
checker = TimeBasedChecker(self.user, trophy, {'before': '06:00'})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_early_bird_not_achieved(self):
|
||||
"""Test Early Bird trophy not achieved (after 6:00 AM)"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Early Bird',
|
||||
trophy_type=Trophy.TYPE_TIME,
|
||||
checker_class='time_based',
|
||||
checker_params={'before': '06:00'},
|
||||
)
|
||||
|
||||
self.stats.earliest_workout_time = datetime.time(7, 0)
|
||||
self.stats.save()
|
||||
|
||||
checker = TimeBasedChecker(self.user, trophy, {'before': '06:00'})
|
||||
self.assertFalse(checker.check())
|
||||
|
||||
def test_night_owl_achieved(self):
|
||||
"""Test Night Owl trophy (after 9:00 PM)"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Night Owl',
|
||||
trophy_type=Trophy.TYPE_TIME,
|
||||
checker_class='time_based',
|
||||
checker_params={'after': '21:00'},
|
||||
)
|
||||
|
||||
self.stats.latest_workout_time = datetime.time(22, 30)
|
||||
self.stats.save()
|
||||
|
||||
checker = TimeBasedChecker(self.user, trophy, {'after': '21:00'})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_night_owl_not_achieved(self):
|
||||
"""Test Night Owl trophy not achieved (before 9:00 PM)"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Night Owl',
|
||||
trophy_type=Trophy.TYPE_TIME,
|
||||
checker_class='time_based',
|
||||
checker_params={'after': '21:00'},
|
||||
)
|
||||
|
||||
self.stats.latest_workout_time = datetime.time(20, 0)
|
||||
self.stats.save()
|
||||
|
||||
checker = TimeBasedChecker(self.user, trophy, {'after': '21:00'})
|
||||
self.assertFalse(checker.check())
|
||||
|
||||
def test_no_workout_time_recorded(self):
|
||||
"""Test when no workout time is recorded"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Early Bird',
|
||||
trophy_type=Trophy.TYPE_TIME,
|
||||
checker_class='time_based',
|
||||
checker_params={'before': '06:00'},
|
||||
)
|
||||
|
||||
checker = TimeBasedChecker(self.user, trophy, {'before': '06:00'})
|
||||
self.assertFalse(checker.check())
|
||||
|
||||
|
||||
class DateBasedCheckerTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the DateBasedChecker
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
self.trophy = Trophy.objects.create(
|
||||
name='New Year, New Me',
|
||||
trophy_type=Trophy.TYPE_DATE,
|
||||
checker_class='date_based',
|
||||
checker_params={'month': 1, 'day': 1},
|
||||
)
|
||||
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
def test_check_achieved(self):
|
||||
"""Test check returns True when worked out on Jan 1st"""
|
||||
self.stats.worked_out_jan_1 = True
|
||||
self.stats.save()
|
||||
|
||||
checker = DateBasedChecker(self.user, self.trophy, {'month': 1, 'day': 1})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_check_not_achieved(self):
|
||||
"""Test check returns False when not worked out on Jan 1st"""
|
||||
self.stats.worked_out_jan_1 = False
|
||||
self.stats.save()
|
||||
|
||||
checker = DateBasedChecker(self.user, self.trophy, {'month': 1, 'day': 1})
|
||||
self.assertFalse(checker.check())
|
||||
|
||||
def test_get_progress(self):
|
||||
"""Test progress is either 0 or 100 for date-based trophies"""
|
||||
self.stats.worked_out_jan_1 = False
|
||||
self.stats.save()
|
||||
|
||||
checker = DateBasedChecker(self.user, self.trophy, {'month': 1, 'day': 1})
|
||||
self.assertEqual(checker.get_progress(), 0.0)
|
||||
|
||||
self.stats.worked_out_jan_1 = True
|
||||
self.stats.save()
|
||||
|
||||
checker = DateBasedChecker(self.user, self.trophy, {'month': 1, 'day': 1})
|
||||
self.assertEqual(checker.get_progress(), 100.0)
|
||||
|
||||
|
||||
class InactivityReturnCheckerTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the InactivityReturnChecker
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
self.trophy = Trophy.objects.create(
|
||||
name='Phoenix',
|
||||
trophy_type=Trophy.TYPE_OTHER,
|
||||
checker_class='inactivity_return',
|
||||
checker_params={'inactive_days': 30},
|
||||
)
|
||||
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
def test_check_achieved(self):
|
||||
"""Test check returns True when returned after 30+ days inactive"""
|
||||
self.stats.last_inactive_date = datetime.date.today() - datetime.timedelta(days=35)
|
||||
self.stats.last_workout_date = datetime.date.today()
|
||||
self.stats.save()
|
||||
|
||||
checker = InactivityReturnChecker(self.user, self.trophy, {'inactive_days': 30})
|
||||
self.assertTrue(checker.check())
|
||||
|
||||
def test_check_not_achieved_no_inactivity(self):
|
||||
"""Test check returns False when no inactivity period recorded"""
|
||||
self.stats.last_inactive_date = None
|
||||
self.stats.save()
|
||||
|
||||
checker = InactivityReturnChecker(self.user, self.trophy, {'inactive_days': 30})
|
||||
self.assertFalse(checker.check())
|
||||
|
||||
def test_check_not_achieved_no_return(self):
|
||||
"""Test check returns False when inactive but no return"""
|
||||
self.stats.last_inactive_date = datetime.date.today() - datetime.timedelta(days=35)
|
||||
self.stats.last_workout_date = None
|
||||
self.stats.save()
|
||||
|
||||
checker = InactivityReturnChecker(self.user, self.trophy, {'inactive_days': 30})
|
||||
self.assertFalse(checker.check())
|
||||
|
||||
def test_get_progress(self):
|
||||
"""Test progress is either 0 or 100 for inactivity return"""
|
||||
self.stats.last_inactive_date = None
|
||||
self.stats.save()
|
||||
|
||||
checker = InactivityReturnChecker(self.user, self.trophy, {'inactive_days': 30})
|
||||
self.assertEqual(checker.get_progress(), 0.0)
|
||||
|
||||
self.stats.last_inactive_date = datetime.date.today() - datetime.timedelta(days=35)
|
||||
self.stats.last_workout_date = datetime.date.today()
|
||||
self.stats.save()
|
||||
|
||||
checker = InactivityReturnChecker(self.user, self.trophy, {'inactive_days': 30})
|
||||
self.assertEqual(checker.get_progress(), 100.0)
|
||||
369
wger/trophies/tests/test_integration.py
Normal file
369
wger/trophies/tests/test_integration.py
Normal file
@@ -0,0 +1,369 @@
|
||||
# This file is part of wger Workout Manager.
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
|
||||
# Standard Library
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
# Django
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
# wger
|
||||
from wger.core.tests.base_testcase import WgerTestCase
|
||||
from wger.manager.models import (
|
||||
WorkoutLog,
|
||||
WorkoutSession,
|
||||
)
|
||||
from wger.trophies.models import (
|
||||
Trophy,
|
||||
UserStatistics,
|
||||
UserTrophy,
|
||||
)
|
||||
from wger.trophies.services.statistics import UserStatisticsService
|
||||
from wger.trophies.services.trophy import TrophyService
|
||||
|
||||
|
||||
class TrophyIntegrationTestCase(WgerTestCase):
|
||||
"""
|
||||
Integration tests for end-to-end trophy workflows
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
|
||||
# Set recent login to avoid being skipped by should_skip_user
|
||||
from django.utils import timezone
|
||||
self.user.last_login = timezone.now()
|
||||
self.user.save()
|
||||
|
||||
# Delete workout data, migration trophies, and existing data to ensure clean state
|
||||
WorkoutLog.objects.filter(user=self.user).delete()
|
||||
WorkoutSession.objects.filter(user=self.user).delete()
|
||||
Trophy.objects.all().delete()
|
||||
UserTrophy.objects.all().delete()
|
||||
UserStatistics.objects.all().delete()
|
||||
|
||||
# Create the standard trophies
|
||||
self.beginner_trophy = Trophy.objects.create(
|
||||
name='Beginner',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 1},
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
self.lifter_trophy = Trophy.objects.create(
|
||||
name='Lifter',
|
||||
trophy_type=Trophy.TYPE_VOLUME,
|
||||
checker_class='volume',
|
||||
checker_params={'kg': 5000},
|
||||
is_active=True,
|
||||
is_progressive=True,
|
||||
)
|
||||
|
||||
self.unstoppable_trophy = Trophy.objects.create(
|
||||
name='Unstoppable',
|
||||
trophy_type=Trophy.TYPE_SEQUENCE,
|
||||
checker_class='streak',
|
||||
checker_params={'days': 30},
|
||||
is_active=True,
|
||||
is_progressive=True,
|
||||
)
|
||||
|
||||
def test_first_workout_earns_beginner_trophy(self):
|
||||
"""Test that completing first workout earns Beginner trophy"""
|
||||
# Create user statistics
|
||||
stats, _ = UserStatistics.objects.get_or_create(user=self.user, defaults={'total_workouts': 0})
|
||||
|
||||
# Verify no trophies earned yet
|
||||
self.assertEqual(UserTrophy.objects.filter(user=self.user).count(), 0)
|
||||
|
||||
# Simulate first workout
|
||||
stats.total_workouts = 1
|
||||
stats.save()
|
||||
|
||||
# Evaluate trophies
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
|
||||
# Should earn Beginner trophy
|
||||
self.assertEqual(len(awarded), 1)
|
||||
self.assertEqual(awarded[0].trophy, self.beginner_trophy)
|
||||
|
||||
def test_lifting_5000kg_earns_lifter_trophy(self):
|
||||
"""Test that lifting 5000kg total earns Lifter trophy"""
|
||||
# Create user statistics
|
||||
stats, _ = UserStatistics.objects.get_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
'total_workouts': 10,
|
||||
'total_weight_lifted': Decimal('4999'),
|
||||
}
|
||||
)
|
||||
|
||||
# Evaluate - should not earn yet
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
lifter_awards = [a for a in awarded if a.trophy == self.lifter_trophy]
|
||||
self.assertEqual(len(lifter_awards), 0)
|
||||
|
||||
# Lift one more kg
|
||||
stats.total_weight_lifted = Decimal('5000')
|
||||
stats.save()
|
||||
|
||||
# Evaluate again
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
lifter_awards = [a for a in awarded if a.trophy == self.lifter_trophy]
|
||||
|
||||
# Should now earn Lifter trophy
|
||||
self.assertEqual(len(lifter_awards), 1)
|
||||
|
||||
def test_30_day_streak_earns_unstoppable_trophy(self):
|
||||
"""Test that 30-day workout streak earns Unstoppable trophy"""
|
||||
# Create user statistics
|
||||
stats, _ = UserStatistics.objects.get_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
'total_workouts': 30,
|
||||
'current_streak': 29,
|
||||
'last_workout_date': datetime.date.today(),
|
||||
}
|
||||
)
|
||||
|
||||
# Evaluate - should not earn yet (only 29 days)
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
unstoppable_awards = [a for a in awarded if a.trophy == self.unstoppable_trophy]
|
||||
self.assertEqual(len(unstoppable_awards), 0)
|
||||
|
||||
# Extend streak to 30 days
|
||||
stats.current_streak = 30
|
||||
stats.save()
|
||||
|
||||
# Evaluate again
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
unstoppable_awards = [a for a in awarded if a.trophy == self.unstoppable_trophy]
|
||||
|
||||
# Should now earn Unstoppable trophy
|
||||
self.assertEqual(len(unstoppable_awards), 1)
|
||||
|
||||
def test_multiple_trophies_earned_together(self):
|
||||
"""Test user can earn multiple trophies at once"""
|
||||
# Create user statistics with conditions for multiple trophies
|
||||
stats, _ = UserStatistics.objects.get_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
'total_workouts': 1, # Qualifies for Beginner
|
||||
'total_weight_lifted': Decimal('5000'), # Qualifies for Lifter
|
||||
'current_streak': 30, # Qualifies for Unstoppable
|
||||
}
|
||||
)
|
||||
|
||||
# Evaluate all trophies
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
|
||||
# Should earn all three trophies
|
||||
self.assertEqual(len(awarded), 3)
|
||||
|
||||
trophy_ids = {a.trophy.id for a in awarded}
|
||||
self.assertIn(self.beginner_trophy.id, trophy_ids)
|
||||
self.assertIn(self.lifter_trophy.id, trophy_ids)
|
||||
self.assertIn(self.unstoppable_trophy.id, trophy_ids)
|
||||
|
||||
def test_progressive_trophy_shows_partial_progress(self):
|
||||
"""Test progressive trophies show partial progress"""
|
||||
# Create user statistics
|
||||
stats, _ = UserStatistics.objects.get_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
'total_weight_lifted': Decimal('2500'), # 50% of 5000kg
|
||||
}
|
||||
)
|
||||
|
||||
# Get progress for all trophies
|
||||
progress_list = TrophyService.get_all_trophy_progress(self.user)
|
||||
|
||||
# Find Lifter trophy progress
|
||||
lifter_progress = next(
|
||||
(p for p in progress_list if p['trophy'].id == self.lifter_trophy.id),
|
||||
None
|
||||
)
|
||||
|
||||
self.assertIsNotNone(lifter_progress)
|
||||
self.assertEqual(lifter_progress['progress'], 50.0)
|
||||
self.assertFalse(lifter_progress['is_earned'])
|
||||
self.assertEqual(lifter_progress['current_value'], 2500)
|
||||
self.assertEqual(lifter_progress['target_value'], 5000)
|
||||
|
||||
def test_trophy_not_awarded_twice(self):
|
||||
"""Test same trophy is not awarded twice"""
|
||||
# Create user statistics
|
||||
stats, _ = UserStatistics.objects.get_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
'total_workouts': 1,
|
||||
}
|
||||
)
|
||||
|
||||
# Evaluate and earn Beginner trophy
|
||||
awarded1 = TrophyService.evaluate_all_trophies(self.user)
|
||||
self.assertEqual(len(awarded1), 1)
|
||||
|
||||
# Do more workouts
|
||||
stats.total_workouts = 10
|
||||
stats.save()
|
||||
|
||||
# Evaluate again
|
||||
awarded2 = TrophyService.evaluate_all_trophies(self.user)
|
||||
|
||||
# Should not award Beginner again (already earned)
|
||||
self.assertEqual(len(awarded2), 0)
|
||||
self.assertEqual(UserTrophy.objects.filter(user=self.user, trophy=self.beginner_trophy).count(), 1)
|
||||
|
||||
def test_statistics_service_updates_correctly(self):
|
||||
"""Test that statistics service updates all fields correctly"""
|
||||
# Update statistics (with no workout data in test DB)
|
||||
stats = UserStatisticsService.update_statistics(self.user)
|
||||
|
||||
# Verify stats were created and initialized
|
||||
self.assertIsNotNone(stats)
|
||||
self.assertEqual(stats.total_workouts, 0)
|
||||
self.assertEqual(stats.total_weight_lifted, Decimal('0'))
|
||||
self.assertEqual(stats.current_streak, 0)
|
||||
self.assertEqual(stats.longest_streak, 0)
|
||||
|
||||
def test_hidden_trophy_not_visible_until_earned(self):
|
||||
"""Test hidden trophies are not visible until earned"""
|
||||
# Create a hidden trophy
|
||||
hidden_trophy = Trophy.objects.create(
|
||||
name='Secret Achievement',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 100},
|
||||
is_active=True,
|
||||
is_hidden=True,
|
||||
)
|
||||
|
||||
# Get progress without including hidden
|
||||
progress_list = TrophyService.get_all_trophy_progress(self.user, include_hidden=False)
|
||||
|
||||
# Hidden trophy should not be in list
|
||||
trophy_ids = [p['trophy'].id for p in progress_list]
|
||||
self.assertNotIn(hidden_trophy.id, trophy_ids)
|
||||
|
||||
# Award the hidden trophy
|
||||
UserTrophy.objects.create(user=self.user, trophy=hidden_trophy)
|
||||
|
||||
# Get progress again
|
||||
progress_list = TrophyService.get_all_trophy_progress(self.user, include_hidden=False)
|
||||
|
||||
# Hidden trophy should now be visible
|
||||
trophy_ids = [p['trophy'].id for p in progress_list]
|
||||
self.assertIn(hidden_trophy.id, trophy_ids)
|
||||
|
||||
def test_inactive_trophy_not_evaluated(self):
|
||||
"""Test inactive trophies are not evaluated"""
|
||||
# Create user statistics that would qualify for trophy
|
||||
stats, _ = UserStatistics.objects.get_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
'total_workouts': 1,
|
||||
}
|
||||
)
|
||||
|
||||
# Deactivate the Beginner trophy
|
||||
self.beginner_trophy.is_active = False
|
||||
self.beginner_trophy.save()
|
||||
|
||||
# Evaluate trophies
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
|
||||
# Should not earn the inactive trophy
|
||||
beginner_awards = [a for a in awarded if a.trophy == self.beginner_trophy]
|
||||
self.assertEqual(len(beginner_awards), 0)
|
||||
|
||||
def test_reevaluate_trophies_for_multiple_users(self):
|
||||
"""Test re-evaluating trophies for multiple users"""
|
||||
user2 = User.objects.get(username='test')
|
||||
|
||||
# Create statistics for both users
|
||||
UserStatistics.objects.get_or_create(user=self.user, defaults={'total_workouts': 1})
|
||||
UserStatistics.objects.get_or_create(user=user2, defaults={'total_workouts': 1})
|
||||
|
||||
# Set recent login for both
|
||||
from django.utils import timezone
|
||||
self.user.last_login = timezone.now()
|
||||
self.user.save()
|
||||
user2.last_login = timezone.now()
|
||||
user2.save()
|
||||
|
||||
# Re-evaluate all trophies
|
||||
results = TrophyService.reevaluate_trophies()
|
||||
|
||||
# Both users should be checked and earn Beginner trophy
|
||||
self.assertEqual(results['users_checked'], 2)
|
||||
self.assertEqual(results['trophies_awarded'], 2)
|
||||
|
||||
# Verify both users have the trophy
|
||||
self.assertTrue(UserTrophy.objects.filter(user=self.user, trophy=self.beginner_trophy).exists())
|
||||
self.assertTrue(UserTrophy.objects.filter(user=user2, trophy=self.beginner_trophy).exists())
|
||||
|
||||
def test_complete_user_journey(self):
|
||||
"""Test complete user journey: signup -> workouts -> earn trophies"""
|
||||
# Day 1: New user, first workout
|
||||
stats, _ = UserStatistics.objects.get_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
'total_workouts': 1,
|
||||
'total_weight_lifted': Decimal('100'),
|
||||
'current_streak': 1,
|
||||
'last_workout_date': datetime.date.today(),
|
||||
}
|
||||
)
|
||||
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
# Should earn Beginner
|
||||
self.assertEqual(len(awarded), 1)
|
||||
self.assertEqual(awarded[0].trophy.name, 'Beginner')
|
||||
|
||||
# Week 1-4: Consistent workouts, building volume
|
||||
stats.total_workouts = 20
|
||||
stats.total_weight_lifted = Decimal('2500')
|
||||
stats.current_streak = 20
|
||||
stats.save()
|
||||
|
||||
# Check progress
|
||||
progress_list = TrophyService.get_all_trophy_progress(self.user)
|
||||
lifter_progress = next(p for p in progress_list if p['trophy'].id == self.lifter_trophy.id)
|
||||
self.assertEqual(lifter_progress['progress'], 50.0) # Halfway to Lifter
|
||||
|
||||
# Month 2: Reach 5000kg
|
||||
stats.total_weight_lifted = Decimal('5000')
|
||||
stats.save()
|
||||
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
# Should earn Lifter (Beginner already earned)
|
||||
lifter_awards = [a for a in awarded if a.trophy.name == 'Lifter']
|
||||
self.assertEqual(len(lifter_awards), 1)
|
||||
|
||||
# Month 2: Complete 30-day streak
|
||||
stats.current_streak = 30
|
||||
stats.save()
|
||||
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
# Should earn Unstoppable
|
||||
unstoppable_awards = [a for a in awarded if a.trophy.name == 'Unstoppable']
|
||||
self.assertEqual(len(unstoppable_awards), 1)
|
||||
|
||||
# Verify final trophy count
|
||||
total_trophies = UserTrophy.objects.filter(user=self.user).count()
|
||||
self.assertEqual(total_trophies, 3) # Beginner, Lifter, Unstoppable
|
||||
355
wger/trophies/tests/test_models.py
Normal file
355
wger/trophies/tests/test_models.py
Normal file
@@ -0,0 +1,355 @@
|
||||
# This file is part of wger Workout Manager.
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
|
||||
# Standard Library
|
||||
from decimal import Decimal
|
||||
|
||||
# Django
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import IntegrityError
|
||||
|
||||
# wger
|
||||
from wger.core.tests.base_testcase import WgerTestCase
|
||||
from wger.trophies.models import (
|
||||
Trophy,
|
||||
UserStatistics,
|
||||
UserTrophy,
|
||||
)
|
||||
|
||||
|
||||
class TrophyModelTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the Trophy model
|
||||
"""
|
||||
|
||||
def test_create_trophy(self):
|
||||
"""Test creating a trophy"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Test Trophy',
|
||||
description='Test Description',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 10},
|
||||
)
|
||||
|
||||
self.assertEqual(trophy.name, 'Test Trophy')
|
||||
self.assertEqual(trophy.description, 'Test Description')
|
||||
self.assertEqual(trophy.trophy_type, Trophy.TYPE_COUNT)
|
||||
self.assertEqual(trophy.checker_class, 'count_based')
|
||||
self.assertEqual(trophy.checker_params, {'count': 10})
|
||||
|
||||
def test_trophy_defaults(self):
|
||||
"""Test trophy default values"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Test Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
)
|
||||
|
||||
self.assertTrue(trophy.is_active)
|
||||
self.assertFalse(trophy.is_hidden)
|
||||
self.assertFalse(trophy.is_progressive)
|
||||
self.assertEqual(trophy.order, 0)
|
||||
self.assertEqual(trophy.description, '')
|
||||
|
||||
def test_trophy_str(self):
|
||||
"""Test trophy string representation"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Amazing Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
)
|
||||
|
||||
self.assertEqual(str(trophy), 'Amazing Trophy')
|
||||
|
||||
def test_trophy_ordering(self):
|
||||
"""Test trophies are ordered by order field then name"""
|
||||
# Delete any existing trophies from migration
|
||||
Trophy.objects.all().delete()
|
||||
|
||||
trophy1 = Trophy.objects.create(
|
||||
name='B Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
order=2,
|
||||
)
|
||||
trophy2 = Trophy.objects.create(
|
||||
name='A Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
order=1,
|
||||
)
|
||||
trophy3 = Trophy.objects.create(
|
||||
name='C Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
order=1,
|
||||
)
|
||||
|
||||
trophies = list(Trophy.objects.all())
|
||||
# Should be ordered by order (1, 1, 2), then by name (A, C, B)
|
||||
self.assertEqual(trophies[0], trophy2) # A Trophy (order 1)
|
||||
self.assertEqual(trophies[1], trophy3) # C Trophy (order 1)
|
||||
self.assertEqual(trophies[2], trophy1) # B Trophy (order 2)
|
||||
|
||||
def test_trophy_types(self):
|
||||
"""Test all trophy types are available"""
|
||||
types = [choice[0] for choice in Trophy.TROPHY_TYPES]
|
||||
self.assertIn(Trophy.TYPE_TIME, types)
|
||||
self.assertIn(Trophy.TYPE_VOLUME, types)
|
||||
self.assertIn(Trophy.TYPE_COUNT, types)
|
||||
self.assertIn(Trophy.TYPE_SEQUENCE, types)
|
||||
self.assertIn(Trophy.TYPE_DATE, types)
|
||||
self.assertIn(Trophy.TYPE_OTHER, types)
|
||||
|
||||
def test_trophy_uuid_generated(self):
|
||||
"""Test UUID is automatically generated"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Test Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
)
|
||||
|
||||
self.assertIsNotNone(trophy.uuid)
|
||||
self.assertEqual(len(str(trophy.uuid)), 36) # UUID4 string length
|
||||
|
||||
def test_trophy_update(self):
|
||||
"""Test updating a trophy"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Original Name',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
)
|
||||
|
||||
trophy.name = 'Updated Name'
|
||||
trophy.is_active = False
|
||||
trophy.save()
|
||||
|
||||
updated = Trophy.objects.get(pk=trophy.pk)
|
||||
self.assertEqual(updated.name, 'Updated Name')
|
||||
self.assertFalse(updated.is_active)
|
||||
|
||||
def test_trophy_delete(self):
|
||||
"""Test deleting a trophy"""
|
||||
trophy = Trophy.objects.create(
|
||||
name='Test Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
)
|
||||
trophy_id = trophy.pk
|
||||
|
||||
trophy.delete()
|
||||
|
||||
self.assertEqual(Trophy.objects.filter(pk=trophy_id).count(), 0)
|
||||
|
||||
|
||||
class UserTrophyModelTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the UserTrophy model
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
self.trophy = Trophy.objects.create(
|
||||
name='Test Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
)
|
||||
|
||||
def test_award_trophy(self):
|
||||
"""Test awarding a trophy to a user"""
|
||||
user_trophy = UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=self.trophy,
|
||||
)
|
||||
|
||||
self.assertEqual(user_trophy.user, self.user)
|
||||
self.assertEqual(user_trophy.trophy, self.trophy)
|
||||
self.assertIsNotNone(user_trophy.earned_at)
|
||||
|
||||
def test_unique_constraint(self):
|
||||
"""Test a user can't earn the same trophy twice"""
|
||||
UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=self.trophy,
|
||||
)
|
||||
|
||||
# Try to award the same trophy again
|
||||
with self.assertRaises(IntegrityError):
|
||||
UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=self.trophy,
|
||||
)
|
||||
|
||||
def test_progress_field(self):
|
||||
"""Test progress field for progressive trophies"""
|
||||
user_trophy = UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=self.trophy,
|
||||
progress=75.5,
|
||||
)
|
||||
|
||||
self.assertEqual(user_trophy.progress, 75.5)
|
||||
|
||||
def test_is_notified_default(self):
|
||||
"""Test is_notified defaults to False"""
|
||||
user_trophy = UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=self.trophy,
|
||||
)
|
||||
|
||||
self.assertFalse(user_trophy.is_notified)
|
||||
|
||||
def test_multiple_users_same_trophy(self):
|
||||
"""Test multiple users can earn the same trophy"""
|
||||
user2 = User.objects.get(username='test')
|
||||
|
||||
user_trophy1 = UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=self.trophy,
|
||||
)
|
||||
user_trophy2 = UserTrophy.objects.create(
|
||||
user=user2,
|
||||
trophy=self.trophy,
|
||||
)
|
||||
|
||||
self.assertNotEqual(user_trophy1, user_trophy2)
|
||||
self.assertEqual(UserTrophy.objects.filter(trophy=self.trophy).count(), 2)
|
||||
|
||||
def test_user_multiple_trophies(self):
|
||||
"""Test a user can earn multiple different trophies"""
|
||||
trophy2 = Trophy.objects.create(
|
||||
name='Another Trophy',
|
||||
trophy_type=Trophy.TYPE_VOLUME,
|
||||
checker_class='volume',
|
||||
)
|
||||
|
||||
user_trophy1 = UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=self.trophy,
|
||||
)
|
||||
user_trophy2 = UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=trophy2,
|
||||
)
|
||||
|
||||
self.assertEqual(UserTrophy.objects.filter(user=self.user).count(), 2)
|
||||
|
||||
def test_cascade_delete_trophy(self):
|
||||
"""Test deleting a trophy deletes associated UserTrophy records"""
|
||||
UserTrophy.objects.create(
|
||||
user=self.user,
|
||||
trophy=self.trophy,
|
||||
)
|
||||
|
||||
self.assertEqual(UserTrophy.objects.filter(trophy=self.trophy).count(), 1)
|
||||
|
||||
self.trophy.delete()
|
||||
|
||||
self.assertEqual(UserTrophy.objects.filter(user=self.user).count(), 0)
|
||||
|
||||
def test_cascade_delete_user(self):
|
||||
"""Test deleting a user deletes associated UserTrophy records"""
|
||||
test_user = User.objects.create_user(username='temp_user', password='temp')
|
||||
UserTrophy.objects.create(
|
||||
user=test_user,
|
||||
trophy=self.trophy,
|
||||
)
|
||||
|
||||
self.assertEqual(UserTrophy.objects.filter(user=test_user).count(), 1)
|
||||
|
||||
test_user.delete()
|
||||
|
||||
self.assertEqual(UserTrophy.objects.filter(trophy=self.trophy).count(), 0)
|
||||
|
||||
|
||||
class UserStatisticsModelTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the UserStatistics model
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
|
||||
def test_create_statistics(self):
|
||||
"""Test creating user statistics"""
|
||||
stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
self.assertEqual(stats.user, self.user)
|
||||
self.assertIsNotNone(stats.last_updated)
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default values are set correctly"""
|
||||
# Delete any existing statistics and create fresh ones
|
||||
UserStatistics.objects.filter(user=self.user).delete()
|
||||
stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
self.assertEqual(stats.total_weight_lifted, Decimal('0'))
|
||||
self.assertEqual(stats.total_workouts, 0)
|
||||
self.assertEqual(stats.current_streak, 0)
|
||||
self.assertEqual(stats.longest_streak, 0)
|
||||
self.assertEqual(stats.weekend_workout_streak, 0)
|
||||
self.assertIsNone(stats.last_workout_date)
|
||||
self.assertIsNone(stats.earliest_workout_time)
|
||||
self.assertIsNone(stats.latest_workout_time)
|
||||
self.assertIsNone(stats.last_complete_weekend_date)
|
||||
self.assertIsNone(stats.last_inactive_date)
|
||||
self.assertFalse(stats.worked_out_jan_1)
|
||||
|
||||
def test_one_to_one_constraint(self):
|
||||
"""Test one user can only have one statistics record"""
|
||||
# Delete any existing statistics first
|
||||
UserStatistics.objects.filter(user=self.user).delete()
|
||||
|
||||
UserStatistics.objects.create(user=self.user)
|
||||
|
||||
# Try to create another statistics record for the same user
|
||||
with self.assertRaises(IntegrityError):
|
||||
UserStatistics.objects.create(user=self.user)
|
||||
|
||||
def test_update_statistics(self):
|
||||
"""Test updating statistics"""
|
||||
stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
stats.total_weight_lifted = Decimal('5000.50')
|
||||
stats.total_workouts = 25
|
||||
stats.current_streak = 10
|
||||
stats.save()
|
||||
|
||||
updated = UserStatistics.objects.get(user=self.user)
|
||||
self.assertEqual(updated.total_weight_lifted, Decimal('5000.50'))
|
||||
self.assertEqual(updated.total_workouts, 25)
|
||||
self.assertEqual(updated.current_streak, 10)
|
||||
|
||||
def test_cascade_delete_user(self):
|
||||
"""Test deleting a user deletes their statistics"""
|
||||
test_user = User.objects.create_user(username='temp_user', password='temp')
|
||||
UserStatistics.objects.get_or_create(user=test_user)
|
||||
|
||||
self.assertEqual(UserStatistics.objects.filter(user=test_user).count(), 1)
|
||||
|
||||
user_id = test_user.id
|
||||
test_user.delete()
|
||||
|
||||
# Verify statistics for this user ID no longer exist
|
||||
self.assertEqual(UserStatistics.objects.filter(user_id=user_id).count(), 0)
|
||||
|
||||
def test_str_representation(self):
|
||||
"""Test string representation of statistics"""
|
||||
stats, _ = UserStatistics.objects.get_or_create(user=self.user)
|
||||
|
||||
str_repr = str(stats)
|
||||
self.assertIn(self.user.username, str_repr)
|
||||
400
wger/trophies/tests/test_services.py
Normal file
400
wger/trophies/tests/test_services.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# This file is part of wger Workout Manager.
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
|
||||
# Standard Library
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
# wger
|
||||
from wger.core.tests.base_testcase import WgerTestCase
|
||||
from wger.manager.models import (
|
||||
WorkoutLog,
|
||||
WorkoutSession,
|
||||
)
|
||||
from wger.trophies.models import (
|
||||
Trophy,
|
||||
UserStatistics,
|
||||
UserTrophy,
|
||||
)
|
||||
from wger.trophies.services.statistics import UserStatisticsService
|
||||
from wger.trophies.services.trophy import TrophyService
|
||||
|
||||
|
||||
class UserStatisticsServiceTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the UserStatisticsService
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
# Delete workout data and statistics to ensure clean state
|
||||
WorkoutLog.objects.filter(user=self.user).delete()
|
||||
WorkoutSession.objects.filter(user=self.user).delete()
|
||||
UserStatistics.objects.filter(user=self.user).delete()
|
||||
|
||||
def test_get_or_create_creates_new(self):
|
||||
"""Test get_or_create creates statistics if they don't exist"""
|
||||
self.assertEqual(UserStatistics.objects.filter(user=self.user).count(), 0)
|
||||
|
||||
stats = UserStatisticsService.get_or_create_statistics(self.user)
|
||||
|
||||
self.assertIsNotNone(stats)
|
||||
self.assertEqual(stats.user, self.user)
|
||||
self.assertEqual(UserStatistics.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_get_or_create_returns_existing(self):
|
||||
"""Test get_or_create returns existing statistics"""
|
||||
existing, _ = UserStatistics.objects.get_or_create(user=self.user, defaults={'total_workouts': 5})
|
||||
|
||||
stats = UserStatisticsService.get_or_create_statistics(self.user)
|
||||
|
||||
self.assertEqual(stats.pk, existing.pk)
|
||||
self.assertEqual(stats.total_workouts, 5)
|
||||
|
||||
def test_update_statistics_creates_if_missing(self):
|
||||
"""Test update_statistics creates statistics if missing"""
|
||||
self.assertEqual(UserStatistics.objects.filter(user=self.user).count(), 0)
|
||||
|
||||
stats = UserStatisticsService.update_statistics(self.user)
|
||||
|
||||
self.assertIsNotNone(stats)
|
||||
self.assertEqual(UserStatistics.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_update_statistics_default_values(self):
|
||||
"""Test update_statistics with no workout data"""
|
||||
stats = UserStatisticsService.update_statistics(self.user)
|
||||
|
||||
self.assertEqual(stats.total_workouts, 0)
|
||||
self.assertEqual(stats.total_weight_lifted, Decimal('0'))
|
||||
self.assertEqual(stats.current_streak, 0)
|
||||
self.assertEqual(stats.longest_streak, 0)
|
||||
|
||||
def test_handle_workout_deletion(self):
|
||||
"""Test handle_workout_deletion triggers full recalculation"""
|
||||
UserStatistics.objects.get_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
'total_workouts': 10,
|
||||
'total_weight_lifted': Decimal('5000'),
|
||||
}
|
||||
)
|
||||
|
||||
stats = UserStatisticsService.handle_workout_deletion(self.user)
|
||||
|
||||
# Should recalculate from actual workout data (none in test db)
|
||||
self.assertEqual(stats.total_workouts, 0)
|
||||
self.assertEqual(stats.total_weight_lifted, Decimal('0'))
|
||||
|
||||
|
||||
class TrophyServiceTestCase(WgerTestCase):
|
||||
"""
|
||||
Test the TrophyService
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username='admin')
|
||||
|
||||
# Set recent login to avoid being skipped by should_skip_user
|
||||
self.user.last_login = timezone.now()
|
||||
self.user.save()
|
||||
|
||||
# Delete workout data, migration trophies, and existing data to ensure clean state
|
||||
WorkoutLog.objects.filter(user=self.user).delete()
|
||||
WorkoutSession.objects.filter(user=self.user).delete()
|
||||
Trophy.objects.all().delete()
|
||||
UserTrophy.objects.all().delete()
|
||||
UserStatistics.objects.filter(user=self.user).delete()
|
||||
|
||||
self.trophy = Trophy.objects.create(
|
||||
name='Test Trophy',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 1},
|
||||
is_active=True,
|
||||
)
|
||||
# Create statistics for the user
|
||||
self.stats, _ = UserStatistics.objects.get_or_create(
|
||||
user=self.user,
|
||||
defaults={'total_workouts': 0},
|
||||
)
|
||||
|
||||
def test_award_trophy(self):
|
||||
"""Test awarding a trophy to a user"""
|
||||
user_trophy = TrophyService.award_trophy(self.user, self.trophy)
|
||||
|
||||
self.assertIsNotNone(user_trophy)
|
||||
self.assertEqual(user_trophy.user, self.user)
|
||||
self.assertEqual(user_trophy.trophy, self.trophy)
|
||||
self.assertEqual(user_trophy.progress, 100.0)
|
||||
|
||||
def test_award_trophy_idempotent(self):
|
||||
"""Test awarding same trophy twice doesn't create duplicates"""
|
||||
user_trophy1 = TrophyService.award_trophy(self.user, self.trophy)
|
||||
user_trophy2 = TrophyService.award_trophy(self.user, self.trophy)
|
||||
|
||||
self.assertEqual(user_trophy1.pk, user_trophy2.pk)
|
||||
self.assertEqual(UserTrophy.objects.filter(user=self.user, trophy=self.trophy).count(), 1)
|
||||
|
||||
def test_evaluate_trophy_earned(self):
|
||||
"""Test evaluating a trophy that should be earned"""
|
||||
# Set stats so trophy is earned
|
||||
self.stats.total_workouts = 1
|
||||
self.stats.save()
|
||||
|
||||
user_trophy = TrophyService.evaluate_trophy(self.user, self.trophy)
|
||||
|
||||
self.assertIsNotNone(user_trophy)
|
||||
self.assertEqual(user_trophy.trophy, self.trophy)
|
||||
|
||||
def test_evaluate_trophy_not_earned(self):
|
||||
"""Test evaluating a trophy that should not be earned"""
|
||||
# Stats show 0 workouts, trophy requires 1
|
||||
user_trophy = TrophyService.evaluate_trophy(self.user, self.trophy)
|
||||
|
||||
self.assertIsNone(user_trophy)
|
||||
|
||||
def test_evaluate_trophy_already_earned(self):
|
||||
"""Test evaluating a trophy that's already been earned"""
|
||||
# Award the trophy first
|
||||
TrophyService.award_trophy(self.user, self.trophy)
|
||||
|
||||
# Try to evaluate again
|
||||
user_trophy = TrophyService.evaluate_trophy(self.user, self.trophy)
|
||||
|
||||
# Should return None (already earned)
|
||||
self.assertIsNone(user_trophy)
|
||||
|
||||
def test_evaluate_trophy_inactive(self):
|
||||
"""Test evaluating an inactive trophy"""
|
||||
self.trophy.is_active = False
|
||||
self.trophy.save()
|
||||
|
||||
self.stats.total_workouts = 1
|
||||
self.stats.save()
|
||||
|
||||
user_trophy = TrophyService.evaluate_trophy(self.user, self.trophy)
|
||||
|
||||
# Inactive trophies should not be awarded
|
||||
self.assertIsNone(user_trophy)
|
||||
|
||||
def test_evaluate_all_trophies(self):
|
||||
"""Test evaluating all trophies for a user"""
|
||||
# Create multiple trophies
|
||||
trophy2 = Trophy.objects.create(
|
||||
name='Trophy 2',
|
||||
trophy_type=Trophy.TYPE_SEQUENCE,
|
||||
checker_class='streak',
|
||||
checker_params={'days': 5},
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Set stats so first trophy is earned
|
||||
self.stats.total_workouts = 1
|
||||
self.stats.current_streak = 0 # Second trophy not earned
|
||||
self.stats.save()
|
||||
|
||||
awarded = TrophyService.evaluate_all_trophies(self.user)
|
||||
|
||||
# Should award only the first trophy
|
||||
self.assertEqual(len(awarded), 1)
|
||||
self.assertEqual(awarded[0].trophy, self.trophy)
|
||||
|
||||
def test_get_user_trophies(self):
|
||||
"""Test getting all earned trophies for a user"""
|
||||
# Award some trophies
|
||||
TrophyService.award_trophy(self.user, self.trophy)
|
||||
|
||||
trophy2 = Trophy.objects.create(
|
||||
name='Trophy 2',
|
||||
trophy_type=Trophy.TYPE_VOLUME,
|
||||
checker_class='volume',
|
||||
checker_params={'kg': 1000},
|
||||
)
|
||||
TrophyService.award_trophy(self.user, trophy2)
|
||||
|
||||
trophies = TrophyService.get_user_trophies(self.user)
|
||||
|
||||
self.assertEqual(len(trophies), 2)
|
||||
|
||||
def test_get_all_trophy_progress(self):
|
||||
"""Test getting progress for all trophies"""
|
||||
# Create a progressive trophy
|
||||
progressive_trophy = Trophy.objects.create(
|
||||
name='Progressive',
|
||||
trophy_type=Trophy.TYPE_VOLUME,
|
||||
checker_class='volume',
|
||||
checker_params={'kg': 5000},
|
||||
is_progressive=True,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Set some progress
|
||||
self.stats.total_weight_lifted = Decimal('2500')
|
||||
self.stats.save()
|
||||
|
||||
progress_list = TrophyService.get_all_trophy_progress(self.user)
|
||||
|
||||
# Should include both trophies
|
||||
self.assertEqual(len(progress_list), 2)
|
||||
|
||||
# Find the progressive trophy in the list
|
||||
prog_trophy_data = next(
|
||||
(p for p in progress_list if p['trophy'].id == progressive_trophy.id),
|
||||
None
|
||||
)
|
||||
self.assertIsNotNone(prog_trophy_data)
|
||||
self.assertEqual(prog_trophy_data['progress'], 50.0)
|
||||
|
||||
def test_get_all_trophy_progress_hidden_not_earned(self):
|
||||
"""Test hidden trophies are excluded unless earned"""
|
||||
hidden_trophy = Trophy.objects.create(
|
||||
name='Hidden',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 10},
|
||||
is_hidden=True,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
progress_list = TrophyService.get_all_trophy_progress(self.user, include_hidden=False)
|
||||
|
||||
# Hidden trophy should not be in the list
|
||||
hidden_in_list = any(p['trophy'].id == hidden_trophy.id for p in progress_list)
|
||||
self.assertFalse(hidden_in_list)
|
||||
|
||||
def test_get_all_trophy_progress_hidden_earned(self):
|
||||
"""Test hidden trophies are included once earned"""
|
||||
hidden_trophy = Trophy.objects.create(
|
||||
name='Hidden',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 1},
|
||||
is_hidden=True,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Award the hidden trophy
|
||||
TrophyService.award_trophy(self.user, hidden_trophy)
|
||||
|
||||
progress_list = TrophyService.get_all_trophy_progress(self.user, include_hidden=False)
|
||||
|
||||
# Hidden trophy should now be in the list
|
||||
hidden_in_list = any(p['trophy'].id == hidden_trophy.id for p in progress_list)
|
||||
self.assertTrue(hidden_in_list)
|
||||
|
||||
def test_should_skip_user_inactive(self):
|
||||
"""Test skipping inactive users"""
|
||||
# Set user's last login to over 30 days ago
|
||||
self.user.last_login = timezone.now() - timezone.timedelta(days=35)
|
||||
self.user.save()
|
||||
|
||||
should_skip = TrophyService.should_skip_user(self.user)
|
||||
|
||||
self.assertTrue(should_skip)
|
||||
|
||||
def test_should_skip_user_active(self):
|
||||
"""Test not skipping active users"""
|
||||
# Set user's last login to recent
|
||||
self.user.last_login = timezone.now() - timezone.timedelta(days=5)
|
||||
self.user.save()
|
||||
|
||||
should_skip = TrophyService.should_skip_user(self.user)
|
||||
|
||||
self.assertFalse(should_skip)
|
||||
|
||||
@patch('wger.trophies.services.trophy.TROPHIES_ENABLED', False)
|
||||
def test_should_skip_user_trophies_disabled(self):
|
||||
"""Test skipping when trophies are globally disabled"""
|
||||
self.user.last_login = timezone.now()
|
||||
self.user.save()
|
||||
|
||||
should_skip = TrophyService.should_skip_user(self.user)
|
||||
|
||||
self.assertTrue(should_skip)
|
||||
|
||||
def test_reevaluate_trophies(self):
|
||||
"""Test re-evaluating trophies for all users"""
|
||||
# Set recent login for self.user
|
||||
self.user.last_login = timezone.now()
|
||||
self.user.save()
|
||||
|
||||
# Create a second user
|
||||
user2 = User.objects.get(username='test')
|
||||
user2.last_login = timezone.now()
|
||||
user2.save()
|
||||
|
||||
# Create stats for both users
|
||||
UserStatistics.objects.get_or_create(user=user2, defaults={'total_workouts': 1})
|
||||
self.stats.total_workouts = 1
|
||||
self.stats.save()
|
||||
|
||||
# Re-evaluate all trophies
|
||||
results = TrophyService.reevaluate_trophies()
|
||||
|
||||
# Both users should be checked
|
||||
self.assertEqual(results['users_checked'], 2)
|
||||
# Both should earn the trophy (count=1, both have 1 workout)
|
||||
self.assertEqual(results['trophies_awarded'], 2)
|
||||
|
||||
def test_reevaluate_specific_trophy(self):
|
||||
"""Test re-evaluating a specific trophy"""
|
||||
# Create another trophy
|
||||
trophy2 = Trophy.objects.create(
|
||||
name='Trophy 2',
|
||||
trophy_type=Trophy.TYPE_COUNT,
|
||||
checker_class='count_based',
|
||||
checker_params={'count': 5},
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
self.user.last_login = timezone.now()
|
||||
self.user.save()
|
||||
self.stats.total_workouts = 1
|
||||
self.stats.save()
|
||||
|
||||
# Re-evaluate only trophy2
|
||||
results = TrophyService.reevaluate_trophies(trophy_ids=[trophy2.id])
|
||||
|
||||
# Trophy2 requires 5 workouts, user has 1, shouldn't be awarded
|
||||
self.assertEqual(results['trophies_awarded'], 0)
|
||||
|
||||
def test_reevaluate_specific_users(self):
|
||||
"""Test re-evaluating trophies for specific users"""
|
||||
# Set recent login for self.user
|
||||
self.user.last_login = timezone.now()
|
||||
self.user.save()
|
||||
|
||||
user2 = User.objects.get(username='test')
|
||||
user2.last_login = timezone.now()
|
||||
user2.save()
|
||||
|
||||
UserStatistics.objects.get_or_create(user=user2, defaults={'total_workouts': 0})
|
||||
self.stats.total_workouts = 1
|
||||
self.stats.save()
|
||||
|
||||
# Re-evaluate only for self.user
|
||||
results = TrophyService.reevaluate_trophies(user_ids=[self.user.id])
|
||||
|
||||
# Only one user checked
|
||||
self.assertEqual(results['users_checked'], 1)
|
||||
# That user should earn the trophy
|
||||
self.assertEqual(results['trophies_awarded'], 1)
|
||||
11
wger/urls.py
11
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
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user