-
Notifications
You must be signed in to change notification settings - Fork 51
Achievements #573
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
CHSten
wants to merge
37
commits into
f-klubben:next
Choose a base branch
from
CHSten:next
base: next
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Achievements #573
Changes from all commits
Commits
Show all changes
37 commits
Select commit
Hold shift + click to select a range
14d6a4a
made achivements.html file
CHSten 34b4b73
fremskridt
CHSten e18aafd
AchievementAdmin models + testdata
CHSten c7d317d
Add achievement-border-glow.webp via upload
CHSten b4d7578
Added achievements to menu_userinfo.html
CHSten 80ae100
Updated achievements.py & achievement models
CHSten f62f6c0
Added AchievementConstraint model
CHSten 8ed2a8a
it works now!
CHSten cb9b65b
Fixed underscores of private functions
CHSten 4823a2f
added Sales History
CHSten 840a041
Achievements readme
henneboy d951d41
Merge pull request #1 from CHSten/add-achievements-md
CHSten 5e46a67
Changed how achievements are stored
CHSten 6e0d6d1
Merge branch 'next' of https://github.com/CHSten/stregsystemet into next
CHSten cf6bffc
Changed the models + backend
CHSten b640439
Added Types & Comments
CHSten d9a7fb6
Added Alcohol and Caffeine Content
CHSten c9595b0
alcohol content fix
CHSten 11df34d
fixed alcohol content and caffeine content fix
CHSten 07f9fca
Made top percentage round to 2 decimals
CHSten f67bb76
Updated admin.py
CHSten 4503e83
Added Clean() + achievements.md
CHSten 54dd42e
Ran Black Code Formatter on modified files
CHSten 5feb30c
Reduced migrations & Added some tests
CHSten 35cce16
Added tests
CHSten 185a694
ran Black Code Formatter
CHSten 4d9ba00
Tweaked __str__ function to AchievementConstraint
CHSten 2a06f2c
Fixed everything
CHSten f3ea7b4
Did black formatting again
CHSten 52a358a
Fixed failing tests
CHSten ff02d79
Merge branch 'next' into next
CHSten 5b0c222
Minor changes to get_user_leaderboard_position()
CHSten cccf05e
Fixed test issues
CHSten 321074e
Forgot to push file
CHSten ce9fd37
Delete .vscode/settings.json
CHSten 77824dd
Delete .python-version
CHSten e6fc720
Merge branch 'next' into next
CHSten File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
# Achievements | ||
|
||
An achievement is a milestone which is stored in the database for the individual member. | ||
|
||
# Achievements database structure | ||
|
||
`Achievement` defines an achievement with a title, description, and icon. | ||
You can set either Active From or Active Duration to specify when tracking begins. | ||
Linked to one or more tasks that specify the criteria to earn the achievement, plus optional constraints. | ||
|
||
`AchievementConstraint` Optional time-based restrictions (e.g., date, time, weekday). | ||
Useful for limiting when an achievement can be completed. | ||
|
||
`AchievementTask` Defines the requirements a user must meet to earn the achievement. | ||
Includes a goal and a task type (e.g., purchase a specific product, buy from a category, consume a certain amount of alcohol, etc.). | ||
|
||
`AchievementComplete` Records when a member completes an achievement. | ||
Each member can complete an achievement only once. | ||
|
||
## How to Add an Achievement | ||
|
||
### What Achievements Can Track | ||
|
||
- Purchases of specific products or categories | ||
- Any purchase in general | ||
- Amounts of alcohol or caffeine consumed | ||
- Used or remaining funds | ||
|
||
### Optional Constraints | ||
|
||
- Specific months, days, times, or weekdays for completion | ||
|
||
### Steps to Add and Achievement | ||
|
||
1. Log in to the Admin panel: | ||
|
||
- Admin panel: <http://127.0.0.1:8000/admin/> | ||
- Login: `tester:treotreo` | ||
|
||
2. If needed, create new AchievementTask entries that fit your criteria. | ||
3. Create an Achievement entry and link it to one or more tasks. | ||
4. (Optional) Add AchievementConstraint entries to enforce time-based restrictions. | ||
|
||
### Adding Custom Logic | ||
|
||
For achievements requiring unique behavior: | ||
|
||
- Add a new task_type in the AchievementTask model. | ||
- Implement the corresponding logic in the model functions. | ||
- Update the _filter_relevant_sales() function to handle the new task type. |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,230 @@ | ||
from django.db.models import Q, Count, Sum, QuerySet | ||
from django.db import models | ||
from collections import defaultdict | ||
from django.db.models import Prefetch | ||
|
||
from typing import List, Dict, Tuple | ||
from datetime import datetime, timedelta | ||
import pytz | ||
|
||
from stregsystem.models import ( | ||
Product, | ||
Category, | ||
Sale, | ||
Member, | ||
Achievement, | ||
AchievementComplete, | ||
AchievementTask, | ||
AchievementConstraint, | ||
) | ||
|
||
|
||
def get_new_achievements(member: Member, product: Product, amount: int = 1) -> List[Achievement]: | ||
""" | ||
Gets newly acquired achievements after having bought something | ||
(This function assumes that a Sale was JUST made) | ||
""" | ||
|
||
now = datetime.now(tz=pytz.timezone("Europe/Copenhagen")) | ||
|
||
# Step 1: Get IDs of achievements already completed by the member | ||
completed_achievements = AchievementComplete.objects.filter(member=member) | ||
|
||
# Step 2: Filter out achievements already completed | ||
completed_achievement_ids = completed_achievements.values_list('achievement_id', flat=True) | ||
in_progress_achievements = Achievement.objects.exclude(id__in=completed_achievement_ids) | ||
|
||
# Step 3: Find achievements that are relevant to the purchase | ||
related_achievements: List[Achievement] = _filter_active_relevant_achievements( | ||
product, in_progress_achievements, now | ||
) | ||
|
||
# Step 4: Determine which of the related tasks now meet their criteria | ||
completed_achievements: List[Achievement] = _find_completed_achievements(related_achievements, member, now) | ||
|
||
# Step 5: Convert into a dictionary for easy variable retrieval | ||
return completed_achievements | ||
|
||
|
||
def get_acquired_achievements_with_rarity(member: Member) -> List[Tuple[Achievement, float]]: | ||
""" | ||
Gets all acquired achievements for a member along with their rarity. | ||
Rarity is defined as the percentage of members who have acquired the achievement. | ||
""" | ||
|
||
# Get the total number of members who have completed any achievement | ||
total_members = Member.objects.filter(achievementcomplete__isnull=False).distinct().count() | ||
|
||
if total_members == 0: | ||
return [] | ||
|
||
# For each of those achievements, calculate how many members have completed it | ||
achievements_with_counts = Achievement.objects.annotate( | ||
completed_count=Count('achievementcomplete__member', distinct=True) | ||
).filter(achievementcomplete__member=member) | ||
|
||
# Compute rarity as percentage | ||
result = [ | ||
(achievement, round((achievement.completed_count / total_members) * 100, 2)) | ||
for achievement in achievements_with_counts | ||
] | ||
|
||
return result | ||
|
||
|
||
def get_missing_achievements(member: Member) -> QuerySet[Achievement]: | ||
"""Gets all missing achievements for a member""" | ||
completed_achievements = AchievementComplete.objects.filter(member=member) | ||
completed_achievement_ids = completed_achievements.values_list('achievement_id', flat=True) | ||
missing_achievements = Achievement.objects.exclude(id__in=completed_achievement_ids) | ||
|
||
return missing_achievements | ||
|
||
|
||
def get_user_leaderboard_position(member: Member) -> float: | ||
CHSten marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
Returns the top percentage that the member is in | ||
based on number of completed achievements among all users. | ||
Users with the same total share the same rank. | ||
|
||
output is a float between 0.0 and 100.0 (2 decimal places) | ||
""" | ||
# Build leaderboard with total achievement counts | ||
leaderboard = ( | ||
AchievementComplete.objects.all() | ||
.values('member') | ||
.annotate(total=Count('id')) | ||
.order_by('-total', 'member') # tie-break deterministically | ||
) | ||
|
||
if not leaderboard: | ||
return 100.0 | ||
|
||
# Assign ranks with dense ranking | ||
ranks = {} | ||
current_rank = 1 | ||
last_total = None | ||
|
||
for entry in leaderboard: | ||
member_id = entry['member'] | ||
total = entry['total'] | ||
|
||
if total != last_total: | ||
rank = current_rank | ||
# if total == last_total, keep previous rank | ||
|
||
ranks[member_id] = rank | ||
last_total = total | ||
current_rank += 1 | ||
|
||
if member.id not in ranks: | ||
return 100.0 # Member has no achievements | ||
|
||
member_rank = ranks[member.id] | ||
total_ranks = len(set(ranks.values())) # total distinct rank positions | ||
|
||
result = member_rank / total_ranks | ||
return round(result * 100, 2) | ||
|
||
|
||
def _find_completed_achievements( | ||
related_achievements: List[Achievement], member: Member, now: datetime | ||
) -> List[Achievement]: | ||
|
||
# Filter member's sales to match relevant achievement tasks | ||
task_to_sales: Dict[AchievementTask, QuerySet[Sale]] = _filter_relevant_sales(related_achievements, member, now) | ||
|
||
completed_achievements: List[Achievement] = [] | ||
new_completions: List[AchievementComplete] = [] | ||
|
||
for achievement in related_achievements: | ||
tasks = achievement.tasks.all() | ||
|
||
if all(task.is_task_completed(task_to_sales[task], member) for task in tasks): | ||
completed_achievements.append(achievement) | ||
new_completions.append(AchievementComplete(member=member, achievement=achievement)) | ||
|
||
if new_completions: | ||
AchievementComplete.objects.bulk_create(new_completions) | ||
|
||
return completed_achievements | ||
|
||
|
||
def _filter_relevant_sales( | ||
achievements: List[Achievement], member: Member, now: datetime | ||
) -> Dict[AchievementTask, QuerySet[Sale]]: | ||
# Start with all sales for this member, select related to reduce hits | ||
member_sales = Sale.objects.filter(member=member).select_related('product').prefetch_related('product__categories') | ||
task_to_sales: Dict[int, QuerySet[int]] = {} | ||
CHSten marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
for achievement in achievements: | ||
# Determine global time window | ||
if achievement.active_duration: | ||
cutoff_date = now - achievement.active_duration | ||
elif achievement.active_from: | ||
cutoff_date = achievement.active_from | ||
else: | ||
cutoff_date = None | ||
|
||
# Apply constraints | ||
constraints = achievement.constraints.all() | ||
tasks = achievement.tasks.all() | ||
|
||
for task in tasks: | ||
relevant_sales = member_sales | ||
|
||
# Apply global achievement time filter | ||
if cutoff_date: | ||
relevant_sales = relevant_sales.filter(timestamp__gte=cutoff_date) | ||
|
||
# Apply all time-based constraints | ||
for constraint in constraints: | ||
if constraint.month_start and constraint.month_end: | ||
relevant_sales = relevant_sales.filter( | ||
timestamp__month__gte=constraint.month_start, timestamp__month__lte=constraint.month_end | ||
) | ||
if constraint.day_start and constraint.day_end: | ||
relevant_sales = relevant_sales.filter( | ||
timestamp__day__gte=constraint.day_start, timestamp__day__lte=constraint.day_end | ||
) | ||
if constraint.time_start and constraint.time_end: | ||
relevant_sales = relevant_sales.filter( | ||
timestamp__time__gte=constraint.time_start, timestamp__time__lte=constraint.time_end | ||
) | ||
if constraint.weekday is not None: | ||
# Django uses Sunday=1 to Saturday=7 | ||
django_weekday = ((constraint.weekday + 1) % 7) + 1 | ||
relevant_sales = relevant_sales.filter(timestamp__week_day=django_weekday) | ||
|
||
# Filter by product/category if defined on the task | ||
if task.task_type == "product" and task.product: | ||
relevant_sales = relevant_sales.filter(product=task.product) | ||
elif task.task_type == "category" and task.category: | ||
relevant_sales = relevant_sales.filter(product__categories=task.category) | ||
# For other task types, additional logic may be added as needed | ||
|
||
task_to_sales[task] = relevant_sales | ||
|
||
return task_to_sales | ||
|
||
|
||
def _filter_active_relevant_achievements( | ||
product: Product, constraints: QuerySet[Achievement], now: datetime | ||
) -> List[Achievement]: | ||
|
||
# Prefetch constraints and tasks with related product and category data | ||
achievements_qs = constraints.prefetch_related( | ||
Prefetch('constraints'), | ||
Prefetch('tasks', queryset=AchievementTask.objects.select_related('product', 'category')), | ||
) | ||
|
||
# List to store filtered achievements | ||
relevant_achievements: List[Achievement] = [] | ||
|
||
# Iterate through achievements and filter based on activity and relevance | ||
for achievement in achievements_qs: | ||
# Check if the achievement is active and relevant to the purchased product | ||
if achievement.is_active(now) and achievement.is_relevant_for_purchase(product): | ||
relevant_achievements.append(achievement) | ||
|
||
return relevant_achievements |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to have achievement images in the repository? Or is this just for the fixture?