-
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 23 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,49 @@ | ||
| # 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, icon, and optional timing rules. | ||
| Only one of begin_at or duration can be set. | ||
|
|
||
| `AchievementConstraint` is an optional time-based restrictions (e.g., date, time, weekday) tied to an achievement. | ||
| Useful for limiting when an achievement can be completed. | ||
|
|
||
| `AchievementTask` defines what a user must do to earn an achievement. Linked to a product, category, alcohol_content, or caffeine_content — only one of these may be set. | ||
| Supports different task types like spending/remaining funds. | ||
|
|
||
| `AchievementComplete` tracks when a member completes an achievement. Each member can only complete an achievement once. | ||
|
|
||
| ## How to Add an Achievement | ||
|
|
||
| ### What Achievements Can Track | ||
|
|
||
| - Product or category purchase amounts | ||
| - Used or remaining funds | ||
| - Alcohol or caffeine content | ||
|
|
||
| ### 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. Create a new Achievement | ||
| 3. Add one or more AchievementTask entries linked to that achievement | ||
| 4. (*Optional*) Add AchievementConstraint entries if you want time-based restrictions | ||
|
|
||
| ### Adding Custom Logic | ||
|
|
||
| For achievements with unique behavior, add a new task_type in AchievementTask and implement the logic in achievements.py. | ||
|
|
||
| ## Achievement Ideas | ||
| * Quite the bartender: Pomster = Limfjordsporter + Monster | ||
| * Keeper of secrets: buy a fytteturs billet. | ||
| * Ægte Datalog: buy a limfjordsporter | ||
| * Exam Week Warrior – Buy 3 energy drinks or coffees during exam month |
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,258 @@ | ||
| from django.db.models import Q, Count, Sum, QuerySet | ||
| from django.db import models | ||
| from collections import defaultdict | ||
|
|
||
| from typing import List, Dict | ||
| from datetime import datetime, timedelta | ||
| import pytz | ||
|
|
||
| from stregsystem.models import ( | ||
| Product, | ||
| Category, | ||
| Sale, | ||
| Member, | ||
| Achievement, | ||
| AchievementComplete, | ||
| AchievementTask, | ||
| ) | ||
|
|
||
|
|
||
| def get_new_achievements(member: Member, product: Product, amount: int = 1) -> List[Achievement]: | ||
| """Gets newly acquired achievements after having bought something""" | ||
|
|
||
| categories = list(product.categories.values_list('id', flat=True)) | ||
| 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 tasks from the remaining achievements that are relevant to the purchase | ||
| related_achievement_tasks: List[AchievementTask] = _filter_achievement_tasks( | ||
| product, categories, in_progress_achievements, now | ||
| ) | ||
|
|
||
| # Step 4: Determine which of the related tasks now meet their criteria | ||
| completed_achievements: List[Achievement] = _find_completed_achievements(related_achievement_tasks, member, now) | ||
|
|
||
| # Step 5: Convert into a dictionary for easy variable retrieval | ||
| return completed_achievements | ||
|
|
||
|
|
||
| def get_acquired_achievements(member: Member) -> QuerySet[Achievement]: | ||
| """Gets all acquired achievements for a member""" | ||
| completed_achievements = Achievement.objects.filter(achievementcomplete__member=member).distinct() | ||
|
|
||
| return completed_achievements | ||
|
|
||
|
|
||
| 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: | ||
| """ | ||
| Returns the top percentage that the member is in | ||
| based on number of completed achievements among all users. | ||
| Example: 0.1 means top 10%. | ||
| """ | ||
| # Count number of achievements completed per member | ||
| leaderboard = ( | ||
| AchievementComplete.objects.filter(completed_at__isnull=False) | ||
| .values('member') | ||
| .annotate(total=Count('id')) # Count of achievements per user | ||
| .order_by('-total') # Rank by total descending | ||
| ) | ||
|
|
||
| total_members = leaderboard.count() | ||
| if total_members == 0: | ||
| return 1.0 # No data, treat user as lowest rank | ||
|
|
||
| # Find the current user's index (rank) | ||
| for index, entry in enumerate(leaderboard): | ||
| if entry['member'] == member.id: | ||
| position = index + 1 # Convert to 1-based rank | ||
| break | ||
| else: | ||
| return 1.0 # User has no achievements | ||
|
|
||
| return position / total_members # Normalize to percentage | ||
|
|
||
|
|
||
| def _find_completed_achievements( | ||
| related_achievement_tasks: List[AchievementTask], member: Member, now: datetime | ||
| ) -> List[Achievement]: | ||
|
|
||
| # Filter member's sales to match relevant achievement tasks | ||
| task_to_sales: Dict[int, QuerySet[Sale]] = _filter_sales(related_achievement_tasks, member, now) | ||
|
|
||
| # Group tasks by achievement for evaluation | ||
| achievement_groups: Dict[int, List[AchievementTask]] = defaultdict(list) | ||
| for at in related_achievement_tasks: | ||
| achievement_groups[at.achievement_id].append(at) | ||
|
|
||
| completed_achievements: List[Achievement] = [] | ||
| new_completions: List[AchievementComplete] = [] | ||
|
|
||
| for group in achievement_groups.values(): | ||
| is_completed: bool = True | ||
|
|
||
| for at in group: | ||
|
|
||
| task_type = at.task_type | ||
| sales = task_to_sales[at.id] | ||
| used_funds = sales.aggregate(total=Sum('price'))['total'] # Sum of prices | ||
|
|
||
| remaining_funds = member.balance | ||
| alcohol_promille = member.calculate_alcohol_promille() | ||
| caffeine = member.calculate_caffeine_in_body() | ||
|
|
||
| # Evaluate whether the specific task is completed based on type | ||
| if task_type == "default" or task_type == "any": | ||
|
|
||
| if at.alcohol_content and alcohol_promille < (at.goal_count / 100): | ||
| is_completed = False | ||
|
|
||
| elif at.caffeine_content and caffeine < (at.goal_count / 100): | ||
| is_completed = False | ||
|
|
||
| elif (not at.alcohol_content and not at.caffeine_content) and sales.count() < at.goal_count: | ||
| is_completed = False | ||
|
|
||
| elif task_type == "used_funds" and used_funds < at.goal_count: | ||
| is_completed = False | ||
| elif task_type == "remaining_funds" and remaining_funds < at.goal_count: | ||
| is_completed = False | ||
|
|
||
| if is_completed: | ||
| achievement = group[0].achievement | ||
| 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_sales(achievement_tasks: List[AchievementTask], member: Member, now: datetime) -> Dict[int, QuerySet[int]]: | ||
|
|
||
| # Prefetch product and categories to reduce DB hits later | ||
| sales_qs = 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 at in achievement_tasks: | ||
| achievement = at.achievement | ||
| relevant_sales = sales_qs | ||
|
|
||
| # Determine the valid time window for the sales | ||
| if achievement.duration: | ||
| begin_time = now - achievement.duration | ||
| elif achievement.begin_at: | ||
| begin_time = achievement.begin_at | ||
| else: | ||
| begin_time = None | ||
|
|
||
| if begin_time: | ||
| relevant_sales = relevant_sales.filter(timestamp__gte=begin_time) | ||
|
|
||
| # Filter for specific product if defined | ||
| if at.product: | ||
| relevant_sales = relevant_sales.filter(product=at.product) | ||
|
|
||
| # Filter for category match | ||
| if at.category: | ||
| relevant_sales = relevant_sales.filter(product__categories=at.category) | ||
|
|
||
| # Use only sale IDs to reduce payload | ||
| task_to_sales[at.id] = relevant_sales.values_list('id', flat=True) | ||
|
|
||
| return task_to_sales | ||
|
|
||
|
|
||
| def _filter_achievement_tasks( | ||
| product: Product, categories: List[int], in_progress_achievements: QuerySet[Achievement], now: datetime | ||
| ) -> List[AchievementTask]: | ||
|
|
||
| # Load constraint relations in advance to avoid N+1 queries | ||
| achievements_with_constraints = in_progress_achievements.prefetch_related('achievementconstraint_set') | ||
|
|
||
| # Step 1: Filter achievements that are currently "active" | ||
| active_achievements = [a for a in achievements_with_constraints if _is_achievement_active(a, now)] | ||
| active_ids = [a.id for a in active_achievements] | ||
|
|
||
| if not active_ids: | ||
| return AchievementTask.objects.none() # Return empty queryset early | ||
|
|
||
| # Step 2: Get all tasks from the active achievements | ||
| tasks = AchievementTask.objects.filter(achievement_id__in=active_ids) | ||
|
|
||
| # Step 3: Build filter matching product or category depending on task type | ||
| category_or_product = Q() | ||
| if product: | ||
| category_or_product |= Q(product_id=product) | ||
|
|
||
| for category in categories: | ||
| category_or_product |= Q(category_id=category) | ||
|
|
||
| # Step 3.1: Add alcohol/caffeine matching if product has it | ||
| alcohol_or_caffeine_filter = Q() | ||
| if product: | ||
|
|
||
| if product.alcohol_content_ml and product.alcohol_content_ml > 0: | ||
| alcohol_or_caffeine_filter |= Q(alcohol_content=True) | ||
|
|
||
| if product.caffeine_content_mg and product.caffeine_content_mg > 0: | ||
| alcohol_or_caffeine_filter |= Q(caffeine_content=True) | ||
|
|
||
| # Step 4: Combine with supported task types | ||
| matching_filter = ( | ||
| Q(task_type="any") | ||
| | Q(task_type="used_funds") | ||
| | Q(task_type="remaining_funds") | ||
| | (Q(task_type="default") & (category_or_product | alcohol_or_caffeine_filter)) | ||
| ) | ||
|
|
||
| # Step 5: Only include achievements with at least one matching task | ||
| matching_achievement_ids = set(tasks.filter(matching_filter).values_list("achievement_id", flat=True)) | ||
|
|
||
| # Step 6: Return all tasks from matching achievements | ||
| return list( | ||
| AchievementTask.objects.filter(achievement_id__in=matching_achievement_ids).select_related( | ||
| 'achievement', 'product', 'category' | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
| def _is_achievement_active(achievement: Achievement, now: datetime) -> bool: | ||
| constraints = achievement.achievementconstraint_set.all() | ||
| if not constraints: | ||
| return True # No constraint means always active | ||
|
|
||
| for c in constraints.all(): | ||
| # All checks must *fail* to continue; pass means active | ||
| if c.month_start and now.month < c.month_start: | ||
| continue | ||
| if c.month_end and now.month > c.month_end: | ||
| continue | ||
| if c.day_start and now.day < c.day_start: | ||
| continue | ||
| if c.day_end and now.day > c.day_end: | ||
| continue | ||
| if c.time_start and now.time() < c.time_start: | ||
| continue | ||
| if c.time_end and now.time() > c.time_end: | ||
| continue | ||
| if c.weekday and now.strftime("%a").lower()[:3] != c.weekday: | ||
| continue | ||
| return True # At least one constraint matches | ||
|
|
||
| return False # All constraints failed | ||
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.
Uh oh!
There was an error while loading. Please reload this page.