Skip to content
Open
Show file tree
Hide file tree
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 Apr 3, 2025
34b4b73
fremskridt
CHSten Apr 7, 2025
e18aafd
AchievementAdmin models + testdata
CHSten Apr 13, 2025
c7d317d
Add achievement-border-glow.webp via upload
CHSten Apr 13, 2025
b4d7578
Added achievements to menu_userinfo.html
CHSten Apr 15, 2025
80ae100
Updated achievements.py & achievement models
CHSten Apr 18, 2025
f62f6c0
Added AchievementConstraint model
CHSten Apr 25, 2025
8ed2a8a
it works now!
CHSten Apr 25, 2025
cb9b65b
Fixed underscores of private functions
CHSten Apr 29, 2025
4823a2f
added Sales History
CHSten Apr 30, 2025
840a041
Achievements readme
henneboy Apr 30, 2025
d951d41
Merge pull request #1 from CHSten/add-achievements-md
CHSten Apr 30, 2025
5e46a67
Changed how achievements are stored
CHSten Apr 30, 2025
6e0d6d1
Merge branch 'next' of https://github.com/CHSten/stregsystemet into next
CHSten Apr 30, 2025
cf6bffc
Changed the models + backend
CHSten May 1, 2025
b640439
Added Types & Comments
CHSten May 7, 2025
d9a7fb6
Added Alcohol and Caffeine Content
CHSten May 7, 2025
c9595b0
alcohol content fix
CHSten May 7, 2025
11df34d
fixed alcohol content and caffeine content fix
CHSten May 7, 2025
07f9fca
Made top percentage round to 2 decimals
CHSten May 7, 2025
f67bb76
Updated admin.py
CHSten May 7, 2025
4503e83
Added Clean() + achievements.md
CHSten May 9, 2025
54dd42e
Ran Black Code Formatter on modified files
CHSten May 9, 2025
5feb30c
Reduced migrations & Added some tests
CHSten May 13, 2025
35cce16
Added tests
CHSten May 14, 2025
185a694
ran Black Code Formatter
CHSten May 14, 2025
4d9ba00
Tweaked __str__ function to AchievementConstraint
CHSten May 14, 2025
2a06f2c
Fixed everything
CHSten May 23, 2025
f3ea7b4
Did black formatting again
CHSten May 23, 2025
52a358a
Fixed failing tests
CHSten May 23, 2025
ff02d79
Merge branch 'next' into next
CHSten Sep 8, 2025
5b0c222
Minor changes to get_user_leaderboard_position()
CHSten Sep 8, 2025
cccf05e
Fixed test issues
CHSten Sep 8, 2025
321074e
Forgot to push file
CHSten Sep 9, 2025
ce9fd37
Delete .vscode/settings.json
CHSten Sep 9, 2025
77824dd
Delete .python-version
CHSten Sep 9, 2025
e6fc720
Merge branch 'next' into next
CHSten Oct 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ Themes
-------
[Read more about themes here.](./themes.md)

Achievements
-------
[Read more about achievements here.](./achievements.md)

Attaching Debugger
-------
[Read about attaching a debugger here.](./debugger.md)
[Read about attaching a debugger here.](./debugger.md)
50 changes: 50 additions & 0 deletions achievements.md
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.
Copy link
Member

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?

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.
230 changes: 230 additions & 0 deletions stregsystem/achievements.py
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:
"""
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]] = {}

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
Loading