Skip to content
Open
Show file tree
Hide file tree
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 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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,7 @@ Using the debugging tool [MailHog](https://github.com/mailhog/MailHog) (Follow t
Themes
-------
[Read more about themes here.](./themes.md)

Achievements
-------
[Read more about achievements here.](./achievements.md)
49 changes: 49 additions & 0 deletions achievements.md
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
258 changes: 258 additions & 0 deletions stregsystem/achievements.py
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]] = {}

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
Loading