diff --git a/README.md b/README.md index 93deb0fb2..6d7c44af3 100644 --- a/README.md +++ b/README.md @@ -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) \ No newline at end of file +[Read about attaching a debugger here.](./debugger.md) diff --git a/achievements.md b/achievements.md new file mode 100644 index 000000000..eb7c7bd76 --- /dev/null +++ b/achievements.md @@ -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: + - 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. \ No newline at end of file diff --git a/media/stregsystem/achievement/achievement_beer.png b/media/stregsystem/achievement/achievement_beer.png new file mode 100644 index 000000000..c25a332b1 Binary files /dev/null and b/media/stregsystem/achievement/achievement_beer.png differ diff --git a/media/stregsystem/achievement/achievement_missing.png b/media/stregsystem/achievement/achievement_missing.png new file mode 100644 index 000000000..c257371fc Binary files /dev/null and b/media/stregsystem/achievement/achievement_missing.png differ diff --git a/stregsystem/achievements.py b/stregsystem/achievements.py new file mode 100644 index 000000000..e94872b66 --- /dev/null +++ b/stregsystem/achievements.py @@ -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 diff --git a/stregsystem/admin.py b/stregsystem/admin.py index 8b4620d77..8379fb866 100644 --- a/stregsystem/admin.py +++ b/stregsystem/admin.py @@ -1,8 +1,15 @@ from django.contrib import admin from django import forms +from django.conf import settings from django.contrib.admin.views.autocomplete import AutocompleteJsonView from django.contrib import messages from django.contrib.admin.models import LogEntry +from django.core.exceptions import ValidationError +from django.utils.html import format_html +from datetime import datetime +import hashlib +import pytz +import os from stregsystem.models import ( Category, @@ -18,6 +25,10 @@ PendingSignup, Theme, ProductNote, + Achievement, + AchievementComplete, + AchievementTask, + AchievementConstraint, ) from stregsystem.templatetags.stregsystem_extras import money from stregsystem.utils import make_active_productlist_query, make_inactive_productlist_query @@ -191,7 +202,7 @@ class NamedProductAdmin(admin.ModelAdmin): class CategoryAdmin(admin.ModelAdmin): - list_display = ('name', 'items_in_category') + list_display = ('id', 'name', 'items_in_category') def items_in_category(self, obj): return obj.product_set.count() @@ -373,6 +384,191 @@ class ProductNoteAdmin(admin.ModelAdmin): actions = [toggle_active_selected_products] +class AchievementForm(forms.ModelForm): + existing_icons = forms.ChoiceField(label="Or choose an existing image", required=False, choices=[]) + + class Meta: + model = Achievement + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + folder_path = os.path.join(settings.MEDIA_ROOT, 'stregsystem/achievement') + choices = [('', '---')] + if os.path.exists(folder_path): + for filename in sorted(os.listdir(folder_path)): + if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): + path = os.path.join('stregsystem/achievement', filename) + choices.append((path, filename)) + self.fields['existing_icons'].choices = choices + + def save(self, commit=True): + instance = super().save(commit=False) + + new_upload = self.files.get('icon') + selected_icon_path = self.cleaned_data.get('existing_icons') + + if new_upload: + uploaded_bytes = new_upload.read() + uploaded_hash = hashlib.md5(uploaded_bytes).hexdigest() + + folder_path = os.path.join(settings.MEDIA_ROOT, 'stregsystem/achievement') + match_found = False + + for filename in os.listdir(folder_path): + file_path = os.path.join(folder_path, filename) + + # Check for matching hash + with open(file_path, 'rb') as f: + existing_hash = hashlib.md5(f.read()).hexdigest() + if uploaded_hash == existing_hash: + # Match found — use existing file + instance.icon.name = os.path.join('stregsystem/achievement', filename) + match_found = True + break + + if not match_found: + # No match — reset file pointer and let Django upload it + new_upload.seek(0) # important! + instance.icon = new_upload + + elif selected_icon_path: + # No upload, but existing image selected + instance.icon.name = selected_icon_path + + if commit: + instance.save() + return instance + + +class AchievementAdmin(admin.ModelAdmin): + form = AchievementForm + + search_fields = ['title', 'description'] + list_display = ['title', 'description', 'get_icon', 'get_active_from_or_active_duration'] + + fieldsets = ( + (None, {'fields': ('title', 'description')}), + (None, {'fields': (('icon', 'existing_icons'),)}), + (None, {'fields': ('tasks', 'constraints')}), + (None, {'fields': (('active_from', 'active_duration'),)}), + ) + + def get_icon(self, obj): + if obj.icon: + filename = obj.icon.name.rsplit('/', 1)[-1] + filename = filename.rsplit('\\', 1)[-1] + return format_html(' {}', obj.icon.url, filename) + return "-" + + get_icon.short_description = 'Icon' + + def get_active_from_or_active_duration(self, obj): + if obj.active_from is not None: + return f"Active From: {obj.active_from.strftime('%Y-%m-%d %H:%M:%S')}" + elif obj.active_duration is not None: + return f"Active Duration: {obj.active_duration}" + + get_active_from_or_active_duration.short_description = "Active-From / -Duration" + + @admin.action(description="Set Active From to now") + def set_active_from_to_now(self, request, queryset): + tz = pytz.timezone("Europe/Copenhagen") + for obj in queryset: + obj.active_from = datetime.now(tz=pytz.timezone("Europe/Copenhagen")) + obj.full_clean() + obj.save() + + @admin.action(description="Set Active From to None") + def set_active_from_to_null(self, request, queryset): + for obj in queryset: + obj.active_from = None + obj.full_clean() + obj.save() + + actions = [set_active_from_to_now, set_active_from_to_null] + + +class AchievementTaskAdmin(admin.ModelAdmin): + list_display = [ + 'notes', + 'task_type', + 'goal_value', + 'get_product', + 'category', + ] + + def get_product(self, obj): + if obj.product: + name = str(obj.product) + return name[:20] + "..." if len(name) > 20 else name + return "" + + get_product.short_description = "Product" + + +class AchievementCompleteAdmin(admin.ModelAdmin): + + valid_lookups = ['member', 'achievement'] + search_fields = ['member__username', 'achievement__title', 'achievement__description', 'completed_at'] + list_display = ['get_username', 'get_achievement_title', 'get_achievement_description', 'completed_at'] + + def get_username(self, obj): + return obj.member.username + + def get_achievement_title(self, obj): + return obj.achievement.title + + get_achievement_title.short_description = 'Achievement Title' + + def get_achievement_description(self, obj): + return obj.achievement.description + + get_achievement_description.short_description = 'Achievement Description' + + +class AchievementConstraintAdmin(admin.ModelAdmin): + list_display = [ + 'notes', + 'month_start', + 'month_end', + 'day_start', + 'day_end', + 'time_start', + 'time_end', + 'weekday', + ] + + fieldsets = ( + (None, {'fields': ['notes']}), + ( + None, + { + 'fields': ['month_start', 'month_end'], + }, + ), + ( + None, + { + 'fields': ['day_start', 'day_end'], + }, + ), + ( + None, + { + 'fields': ['time_start', 'time_end'], + }, + ), + ( + None, + { + 'fields': ['weekday'], + }, + ), + ) + + admin.site.register(LogEntry, LogEntryAdmin) admin.site.register(Sale, SaleAdmin) admin.site.register(Member, MemberAdmin) @@ -386,3 +582,7 @@ class ProductNoteAdmin(admin.ModelAdmin): admin.site.register(PendingSignup) admin.site.register(Theme, ThemeAdmin) admin.site.register(ProductNote, ProductNoteAdmin) +admin.site.register(Achievement, AchievementAdmin) +admin.site.register(AchievementTask, AchievementTaskAdmin) +admin.site.register(AchievementComplete, AchievementCompleteAdmin) +admin.site.register(AchievementConstraint, AchievementConstraintAdmin) diff --git a/stregsystem/fixtures/testdata-achievements.json b/stregsystem/fixtures/testdata-achievements.json new file mode 100644 index 000000000..79eb6b098 --- /dev/null +++ b/stregsystem/fixtures/testdata-achievements.json @@ -0,0 +1,221 @@ +[ + { + "model": "stregsystem.achievement", + "pk": 1, + "fields": { + "title": "First Purchase", + "description": "Make your first purchase!", + "icon":"stregsystem/achievement/achievement_beer.png", + "tasks": [1] + } + }, + { + "model": "stregsystem.achievementtask", + "pk": 1, + "fields": { + "task_type": "any_purchase", + "goal_value": 1, + "notes": "Any purchase" + } + }, + { + "model": "stregsystem.achievement", + "pk": 2, + "fields": { + "title": "Beginning Acoholic!", + "description": "Buy your first beer.", + "icon":"stregsystem/achievement/achievement_beer.png", + "tasks": [2] + } + }, + { + "model": "stregsystem.achievementtask", + "pk": 2, + "fields": { + "task_type": "product", + "product": 14, + "goal_value": 1, + "notes": "Buy one beer" + } + }, + { + "model": "stregsystem.achievement", + "pk": 3, + "fields": { + "title": "Party Starter", + "description": "Buy five drinks.", + "icon":"stregsystem/achievement/achievement_beer.png", + "tasks": [3] + } + }, + { + "model": "stregsystem.achievementtask", + "pk": 3, + "fields": { + "task_type": "category", + "category": 3, + "goal_value": 5, + "notes": "Buy five drinks" + } + }, + { + "model": "stregsystem.achievement", + "pk": 4, + "fields": { + "title": "Beer Enthusiast", + "description": "Buy 10 beers.", + "icon":"stregsystem/achievement/achievement_beer.png", + "tasks": [4] + } + }, + { + "model": "stregsystem.achievementtask", + "pk": 4, + "fields": { + "task_type": "product", + "product": 14, + "goal_value": 10, + "notes": "Buy 10 beers" + } + }, + { + "model": "stregsystem.achievement", + "pk": 5, + "fields": { + "title": "Big Spender", + "description": "Spend over 500 kr.", + "icon":"stregsystem/achievement/achievement_beer.png", + "tasks": [5] + } + }, + { + "model": "stregsystem.achievementtask", + "pk": 5, + "fields": { + "task_type": "used_funds", + "goal_value": 50000, + "notes": "Use 500 kr." + } + }, + { + "model": "stregsystem.achievement", + "pk": 6, + "fields": { + "title": "Drugged up!", + "description": "Buy a beer and a coffee within a minute", + "icon":"stregsystem/achievement/achievement_beer.png", + "active_duration": "00:01:00", + "tasks": [6, 2] + } + }, + { + "model": "stregsystem.achievementtask", + "pk": 6, + "fields": { + "task_type": "category", + "category": 6, + "goal_value": 1, + "notes": "Buy one from Caffeine Category" + } + }, + { + "model": "stregsystem.achievement", + "pk": 7, + "fields": { + "title": "Heavy Drinker", + "description": "Buy 20 beers.", + "icon":"stregsystem/achievement/achievement_beer.png", + "tasks": [7] + } + }, + { + "model": "stregsystem.achievementtask", + "pk": 7, + "fields": { + "task_type": "product", + "product": 14, + "goal_value": 20, + "notes": "Buy 20 beers" + } + }, + { + "model": "stregsystem.achievement", + "pk": 8, + "fields": { + "title": "Night Owl", + "description": "Make purchases after midnight.", + "icon":"stregsystem/achievement/achievement_beer.png", + "tasks": [1], + "constraints": [1] + } + }, + { + "model": "stregsystem.achievementconstraint", + "pk": 1, + "fields": { + "time_start": "00:00:00", + "time_end": "04:00:00", + "notes": "Between 00:00 and 04:00" + } + }, + { + "model": "stregsystem.achievement", + "pk": 9, + "fields": { + "title": "What a random day huh?", + "description": "Make a purchase the 19th of April.", + "icon":"stregsystem/achievement/achievement_beer.png", + "tasks": [1], + "constraints": [2] + } + }, + { + "model": "stregsystem.achievementconstraint", + "pk": 2, + "fields": { + "day_start": 19, + "day_end": 19, + "month_start": 4, + "month_end": 4, + "notes": "19th April" + } + }, + { + "model": "stregsystem.achievement", + "pk": 10, + "fields": { + "title": "It is Wednesday my coder!", + "description": "Make a purchase on a Wednesday.", + "icon":"stregsystem/achievement/achievement_beer.png", + "tasks": [1], + "constraints": [3] + } + }, + { + "model": "stregsystem.achievementconstraint", + "pk": 3, + "fields": { + "weekday": 2, + "notes": "A Wednesday" + } + }, + { + "model": "stregsystem.achievement", + "pk": 11, + "fields": { + "title": "Drunk MF'er", + "description": "You are now drunk.", + "icon":"stregsystem/achievement/achievement_beer.png", + "tasks": [8] + } + }, + { + "model": "stregsystem.achievementtask", + "pk": 8, + "fields": { + "task_type": "alcohol_content", + "goal_value": 300, + "notes": "Alcohol Content of 3.0" + } + } +] diff --git a/stregsystem/migrations/0023_achievementconstraint_achievementtask_achievement_and_more.py b/stregsystem/migrations/0023_achievementconstraint_achievementtask_achievement_and_more.py new file mode 100644 index 000000000..932b890fd --- /dev/null +++ b/stregsystem/migrations/0023_achievementconstraint_achievementtask_achievement_and_more.py @@ -0,0 +1,277 @@ +# Generated by Django 4.1.13 on 2025-05-23 12:16 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("stregsystem", "0022_productnote"), + ] + + operations = [ + migrations.CreateModel( + name="AchievementConstraint", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("notes", models.CharField(blank=True, max_length=200)), + ( + "month_start", + models.IntegerField( + blank=True, + choices=[ + (1, "January"), + (2, "Feburary"), + (3, "March"), + (4, "April"), + (5, "May"), + (6, "June"), + (7, "July"), + (8, "August"), + (9, "September"), + (10, "October"), + (11, "November"), + (12, "December"), + ], + help_text="If not set, other constraints to no specific months. (requires Month End).", + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(12), + ], + ), + ), + ( + "month_end", + models.IntegerField( + blank=True, + choices=[ + (1, "January"), + (2, "Feburary"), + (3, "March"), + (4, "April"), + (5, "May"), + (6, "June"), + (7, "July"), + (8, "August"), + (9, "September"), + (10, "October"), + (11, "November"), + (12, "December"), + ], + help_text="If not set, other constraints to no specific months. (requires Month Start).", + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(12), + ], + ), + ), + ( + "day_start", + models.IntegerField( + blank=True, + help_text="If not set, constraints apply to no specific days. (requires Day End).", + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(31), + ], + ), + ), + ( + "day_end", + models.IntegerField( + blank=True, + help_text="If not set, other constraints apply no specfic days. (requires Day Start).", + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(31), + ], + ), + ), + ( + "time_start", + models.TimeField( + blank=True, + help_text="If not set, other constraints apply no specfic time range. (requires Time End).", + null=True, + ), + ), + ( + "time_end", + models.TimeField( + blank=True, + help_text="If not set, other constraints apply no specfic time range. (requires Time Start).", + null=True, + ), + ), + ( + "weekday", + models.IntegerField( + blank=True, + choices=[ + (0, "Monday"), + (1, "Tuesday"), + (2, "Wednesday"), + (3, "Thursday"), + (4, "Friday"), + (5, "Saturday"), + (6, "Sunday"), + ], + help_text="If not set, other constraints apply no specfic weekday.", + null=True, + ), + ), + ], + ), + migrations.CreateModel( + name="AchievementTask", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("notes", models.CharField(blank=True, max_length=200)), + ( + "task_type", + models.CharField( + choices=[ + ("product", "Specific Product"), + ("category", "Product Category"), + ("any_purchase", "Any Purchase"), + ("alcohol_content", "Alcohol Content"), + ("caffeine_content", "Caffeine Content"), + ("used_funds", "Used Funds"), + ("remaining_funds", "Remaining Funds"), + ], + max_length=50, + ), + ), + ( + "goal_value", + models.IntegerField( + help_text="E.g. 300 = 3.00ml or mg. For funds: 500 = 5.00 kr." + ), + ), + ( + "category", + models.ForeignKey( + blank=True, + help_text="Only has to be set, if 'Product Category' was chosen as the Task Type.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="stregsystem.category", + ), + ), + ( + "product", + models.ForeignKey( + blank=True, + help_text="Only has to be set, if 'Specific Product' was chosen as the Task Type.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="stregsystem.product", + ), + ), + ], + ), + migrations.CreateModel( + name="Achievement", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=50)), + ("description", models.CharField(max_length=100)), + ("icon", models.ImageField(upload_to="stregsystem/achievement")), + ( + "active_from", + models.DateTimeField( + blank=True, + help_text="Start datetime for tracking. Conflicts with 'Active Duration'. Leave both blank for all-time history.", + null=True, + ), + ), + ( + "active_duration", + models.DurationField( + blank=True, + help_text="Time window for tracking. Conflicts with 'Active From'. Leave both blank for all-time history.", + null=True, + ), + ), + ( + "constraints", + models.ManyToManyField( + blank=True, + help_text="Optional time-based constraints for this achievement.", + related_name="achievements", + to="stregsystem.achievementconstraint", + ), + ), + ( + "tasks", + models.ManyToManyField( + help_text="Tasks that must be completed to earn this achievement.", + related_name="achievements", + to="stregsystem.achievementtask", + ), + ), + ], + ), + migrations.CreateModel( + name="AchievementComplete", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("completed_at", models.DateTimeField(auto_now_add=True)), + ( + "achievement", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="stregsystem.achievement", + ), + ), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="stregsystem.member", + ), + ), + ], + options={ + "unique_together": {("member", "achievement")}, + }, + ), + ] diff --git a/stregsystem/models.py b/stregsystem/models.py index 1cb7fed29..f0548a7a8 100644 --- a/stregsystem/models.py +++ b/stregsystem/models.py @@ -3,14 +3,17 @@ from abc import abstractmethod from collections import Counter from email.utils import parseaddr +from typing import List, Dict, Tuple from django.contrib.admin.models import LogEntry, ADDITION, CHANGE from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.validators import RegexValidator from django.db import models, transaction -from django.db.models import Count +from django.db.models import Count, Sum from django.utils import timezone +from django.core.validators import MinValueValidator, MaxValueValidator +from django.core.exceptions import ValidationError from stregsystem.caffeine import Intake, CAFFEINE_TIME_INTERVAL, current_caffeine_in_body_compound_interest from stregsystem.deprecated import deprecated @@ -863,3 +866,349 @@ class Meta: def __str__(self): return self.name + + +class Achievement(models.Model): + title = models.CharField(max_length=50) + description = models.CharField(max_length=100) + icon = models.ImageField(upload_to="stregsystem/achievement") + + active_from = models.DateTimeField( + null=True, + blank=True, + help_text="Start datetime for tracking. Conflicts with 'Active Duration'. Leave both blank for all-time history.", + ) + + active_duration = models.DurationField( + null=True, + blank=True, + help_text="Time window for tracking. Conflicts with 'Active From'. Leave both blank for all-time history.", + ) + + constraints = models.ManyToManyField( + 'AchievementConstraint', + blank=True, + related_name='achievements', + help_text="Optional time-based constraints for this achievement.", + ) + + tasks = models.ManyToManyField( + 'AchievementTask', + related_name='achievements', + help_text="Tasks that must be completed to earn this achievement.", + ) + + def is_active(self, now: datetime) -> bool: + constraints: AchievementConstraint = self.constraints.all() + + if not constraints.exists(): + return True + + return all(c.is_active(now) for c in constraints) # All constraints needs to be active + + def is_relevant_for_purchase(self, product: Product) -> bool: + tasks: AchievementTask = self.tasks.all() + + return any(t.is_relevant_for_purchase(product) for t in tasks) # Only one task needs to be relevant + + def clean(self): + super().clean() + if self.active_from and self.active_duration: + raise ValidationError("Only one of 'Active From' or 'Active Duration' can be set, or neither.") + + if not self.pk or not self.tasks.exists(): + raise ValidationError("An achievement must have at least one task.") + + def __str__(self): + str_list = [f"{self.title} - {self.description}"] + + if self.active_from: + str_list.append(f"Starts: {self.active_from.strftime('%Y-%m-%d')}") + if self.active_duration: + str_list.append(f"Duration: {self.active_duration}") + + return " | ".join(str_list) + + +class AchievementConstraint(models.Model): + notes = models.CharField(max_length=200, blank=True) + + MONTHS = [ + (1, "January"), + (2, "Feburary"), + (3, "March"), + (4, "April"), + (5, "May"), + (6, "June"), + (7, "July"), + (8, "August"), + (9, "September"), + (10, "October"), + (11, "November"), + (12, "December"), + ] + + month_start = models.IntegerField( + choices=MONTHS, + null=True, + blank=True, + validators=[MinValueValidator(1), MaxValueValidator(12)], + help_text="If not set, other constraints to no specific months. (requires Month End).", + ) + + month_end = models.IntegerField( + choices=MONTHS, + null=True, + blank=True, + validators=[MinValueValidator(1), MaxValueValidator(12)], + help_text="If not set, other constraints to no specific months. (requires Month Start).", + ) + + day_start = models.IntegerField( + null=True, + blank=True, + validators=[MinValueValidator(1), MaxValueValidator(31)], + help_text="If not set, constraints apply to no specific days. (requires Day End).", + ) + + day_end = models.IntegerField( + null=True, + blank=True, + validators=[MinValueValidator(1), MaxValueValidator(31)], + help_text="If not set, other constraints apply no specfic days. (requires Day Start).", + ) + + time_start = models.TimeField( + null=True, + blank=True, + help_text="If not set, other constraints apply no specfic time range. (requires Time End).", + ) + + time_end = models.TimeField( + null=True, + blank=True, + help_text="If not set, other constraints apply no specfic time range. (requires Time Start).", + ) + + WEEK_DAYS = [ + (0, "Monday"), + (1, "Tuesday"), + (2, "Wednesday"), + (3, "Thursday"), + (4, "Friday"), + (5, "Saturday"), + (6, "Sunday"), + ] + + weekday = models.IntegerField( + choices=WEEK_DAYS, null=True, blank=True, help_text="If not set, other constraints apply no specfic weekday." + ) + + def is_active(self, now: datetime) -> bool: + return ( + (not self.month_start or now.month >= self.month_start) + and (not self.month_end or now.month <= self.month_end) + and (not self.day_start or now.day >= self.day_start) + and (not self.day_end or now.day <= self.day_end) + and (not self.time_start or now.time() >= self.time_start) + and (not self.time_end or now.time() <= self.time_end) + and (self.weekday is None or now.weekday() == self.weekday) + ) + + def clean(self): + errors = {} + + # Helper to validate pairs + def validate_pair(start, end, wrap_around=False): + start_val = getattr(self, start) + end_val = getattr(self, end) + + if start_val is not None and end_val is None: + errors[end] = f"{start} must be set if {end} is set." + elif end_val is not None and start_val is None: + errors[start] = f"{start} must be set if {end} is set." + elif start_val is not None and end_val is not None and not wrap_around: + if start_val > end_val: + errors[start] = f"{start} must be less than or equal to {end}." + + validate_pair('month_start', 'month_end') + validate_pair('day_start', 'day_end') + validate_pair('time_start', 'time_end', wrap_around=True) + + if errors: + raise ValidationError(errors) + + def __str__(self): + str_list = [] + + if self.notes != "": + return self.notes + + if self.month_start and self.month_end: + str_list.append(f"Months: {self.month_start}-{self.month_end}") + if self.day_start and self.day_end: + str_list.append(f"Days: {self.day_start}-{self.day_end}") + if self.time_start and self.time_end: + str_list.append(f"Time: {self.time_start.strftime('%H:%M')}–{self.time_end.strftime('%H:%M')}") + if self.weekday is not None: + weekday_dict = dict(self.WEEK_DAYS) + str_list.append(f"Weekday: {weekday_dict[int(self.weekday)]}") + + return ", ".join(str_list) + + +class AchievementTask(models.Model): + notes = models.CharField(max_length=200, blank=True) + + TASK_TYPES = [ + # Specific item types + ("product", "Specific Product"), + ("category", "Product Category"), + # Broad purchase-based task + ("any_purchase", "Any Purchase"), + # Content-based goals + ("alcohol_content", "Alcohol Content"), + ("caffeine_content", "Caffeine Content"), + # Financial-based goals + ("used_funds", "Used Funds"), + ("remaining_funds", "Remaining Funds"), + ] + task_type = models.CharField( + max_length=50, + choices=TASK_TYPES, + null=False, + blank=False, + ) + + product = models.ForeignKey( + Product, + on_delete=models.CASCADE, + null=True, + blank=True, + help_text="Only has to be set, if 'Specific Product' was chosen as the Task Type.", + ) + + category = models.ForeignKey( + Category, + on_delete=models.CASCADE, + null=True, + blank=True, + help_text="Only has to be set, if 'Product Category' was chosen as the Task Type.", + ) + + goal_value = models.IntegerField(help_text="E.g. 300 = 3.00ml or mg. For funds: 500 = 5.00 kr.") + + def is_relevant(self, product: Product, categories: List[int]) -> bool: + """ + Returns True if the task is relevant for the given product and categories. + """ + if self.task_type in ["any_purchase", "used_funds", "remaining_funds"]: + return True + if self.task_type == "product" and self.product_id == product.id: + return True + if self.task_type == "category" and self.category_id in categories: + return True + if self.task_type == "alcohol_content" and getattr(product, 'alcohol_content_ml', 0) > 0: + return True + if self.task_type == "caffeine_content" and getattr(product, 'caffeine_content_mg', 0) > 0: + return True + + return False + + def is_task_completed(self, sales: List[Sale], member: Member) -> bool: + """ + Determines if the task is completed based on the sales and member's attributes. + """ + task_type = self.task_type + 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() + + if ( + task_type == "product" or task_type == "category" or task_type == "any_purchase" + ) and sales.count() < self.goal_value: + return False + elif task_type == "alcohol_content" and alcohol_promille < (self.goal_value / 100): + return False + elif task_type == "caffeine_content" and caffeine < (self.goal_value / 100): + return False + elif task_type == "used_funds" and used_funds < self.goal_value: + return False + elif task_type == "remaining_funds" and remaining_funds < self.goal_value: + return False + + return True + + def clean(self): + super().clean() + + if not self.task_type: + raise ValidationError("Task type must be selected.") + + if self.task_type == "product": + if not self.product: + raise ValidationError("Product must be set if task_type is 'product'.") + if self.category: + raise ValidationError("Category must not be set when task_type is 'product'.") + elif self.task_type == "category": + if not self.category: + raise ValidationError("Category must be set if task_type is 'category'.") + if self.product: + raise ValidationError("Product must not be set when task_type is 'category'.") + elif self.task_type in ("alcohol", "caffeine"): + if self.product or self.category: + raise ValidationError("Product and Category must not be set when target is alcohol or caffeine.") + + # Ensure goal_value is positive + if self.goal_value <= 0: + raise ValidationError("Goal value must be greater than 0.") + + def is_relevant_for_purchase(self, product: Product) -> bool: + if self.task_type in ["any_purchase", "used_funds", "remaining_funds"]: + return True + if self.task_type == "product" and self.product == product: + return True + if self.task_type == "category" and self.category in product.categories.all(): + return True + if self.task_type == "alcohol_content" and getattr(product, 'alcohol_content_ml', 0) > 0: + return True + if self.task_type == "caffeine_content" and getattr(product, 'caffeine_content_mg', 0) > 0: + return True + + return False + + def __str__(self): + str_list = [] + + if self.notes != "": + return self.notes + + if self.task_type == "product" and self.product: + str_list.append(f"Product: {self.product.name}") + elif self.task_type == "category" and self.category: + str_list.append(f"Category: {self.category.name}") + elif self.task_type == "any_purchase": + str_list.append("Any Purchase") + elif self.task_type == "alcohol_content": + str_list.append(f"Alcohol Content ≤ {self.goal_value / 100:.2f} ml") + elif self.task_type == "caffeine_content": + str_list.append(f"Caffeine Content ≤ {self.goal_value / 100:.2f} mg") + elif self.task_type == "used_funds": + str_list.append(f"Used Funds ≥ {self.goal_value / 100:.2f} kr") + elif self.task_type == "remaining_funds": + str_list.append(f"Remaining Funds ≤ {self.goal_value / 100:.2f} kr") + + return " | ".join(str_list) + f" - Goal: {self.goal_value}" + + +class AchievementComplete(models.Model): # A members progress on a task + member = models.ForeignKey(Member, on_delete=models.CASCADE) + achievement = models.ForeignKey(Achievement, on_delete=models.CASCADE) + completed_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("member", "achievement") + + def __str__(self): + return f"{self.member.username} ({self.achievement.title})" diff --git a/stregsystem/static/stregsystem/achievement/achievementBG.png b/stregsystem/static/stregsystem/achievement/achievementBG.png new file mode 100644 index 000000000..a6dfa2b73 Binary files /dev/null and b/stregsystem/static/stregsystem/achievement/achievementBG.png differ diff --git a/stregsystem/templates/stregsystem/achievement_notification.html b/stregsystem/templates/stregsystem/achievement_notification.html new file mode 100644 index 000000000..0c9a46da0 --- /dev/null +++ b/stregsystem/templates/stregsystem/achievement_notification.html @@ -0,0 +1,105 @@ +{% load static %} + +{% for achievement in new_achievements %} + +
+ Achievement Icon +
+
{{ achievement.title }}
+
{{ achievement.description }}
+
+
+
+{% endfor %} + + + + \ No newline at end of file diff --git a/stregsystem/templates/stregsystem/index_sale.html b/stregsystem/templates/stregsystem/index_sale.html index 9eb395bf9..ca43b239d 100644 --- a/stregsystem/templates/stregsystem/index_sale.html +++ b/stregsystem/templates/stregsystem/index_sale.html @@ -50,4 +50,7 @@

psssst. multibuy er enabled.

{% endautoescape %}

+ {% block achievement %} + {% include "stregsystem/achievement_notification.html" %} + {% endblock %} {% endblock %} diff --git a/stregsystem/templates/stregsystem/menu.html b/stregsystem/templates/stregsystem/menu.html index 809f1986e..15055d7f8 100644 --- a/stregsystem/templates/stregsystem/menu.html +++ b/stregsystem/templates/stregsystem/menu.html @@ -122,8 +122,10 @@

Du {% endautoescape %} {% endblock %}
- {% include "stregsystem/purchase_heatmap.html" %} - - + {% include "stregsystem/purchase_heatmap.html" %} + + {% block achievement %} + {% include "stregsystem/achievement_notification.html" %} + {% endblock %} {% endblock %} diff --git a/stregsystem/templates/stregsystem/menu_userinfo.html b/stregsystem/templates/stregsystem/menu_userinfo.html index 38dae22be..0c8bd277f 100644 --- a/stregsystem/templates/stregsystem/menu_userinfo.html +++ b/stregsystem/templates/stregsystem/menu_userinfo.html @@ -1,5 +1,7 @@ {% extends "stregsystem/base.html" %} +{% load static %} + {% load stregsystem_extras %} {% block title %}Treoens stregsystem : Brugerinfo {% endblock %} @@ -93,7 +95,89 @@

STREGFORBUD!

Anmod om bruger data + +

Achievements ({{ achievement_progress_str }}):

+

You are Top: {{ achievement_top_percentage }}%

+
+ {% for achievement_tuple in acquired_achievements %} + {% with achievement=achievement_tuple.0 rarity=achievement_tuple.1 color=achievement_tuple.2 %} +
+
{{ rarity }}
+ +
{{ achievement.title }}
+
{{ achievement.description }}
+
+ {% endwith %} + {% endfor %} + {% for achievement in missing_achievements %} +
+ +
{{ achievement.title }}
+
+ {% endfor %} +
-{% endblock %} + + +{% endblock %} \ No newline at end of file diff --git a/stregsystem/tests.py b/stregsystem/tests.py index 319a30d31..204c9d828 100644 --- a/stregsystem/tests.py +++ b/stregsystem/tests.py @@ -43,6 +43,16 @@ NamedProduct, ApprovalModel, ProductNote, + Achievement, + AchievementTask, + AchievementConstraint, + AchievementComplete, +) +from stregsystem.achievements import ( + get_new_achievements, + get_user_leaderboard_position, + get_acquired_achievements_with_rarity, + get_missing_achievements, ) from stregsystem.purchase_heatmap import prepare_heatmap_template_context from stregsystem.templatetags.stregsystem_extras import caffeine_emoji_render @@ -2289,3 +2299,197 @@ def test_welcome_mail_paid_approved(self, mock_mail_method: MagicMock): signup_request.approve() mock_mail_method.assert_called_once() + + +class AchievementLogicTests(TestCase): + def setUp(self): + self.member1 = Member.objects.create(username="testuser1", balance=100) + self.member2 = Member.objects.create(username="testuser2", balance=100) + self.member3 = Member.objects.create(username="testuser3", balance=100) + self.category_beer = Category.objects.create(name="Beer Category") + self.product_beer = Product.objects.create(name="Beer", price=10, alcohol_content_ml=500, active=True) + self.product_beer.categories.add(self.category_beer) + + self.task_beer_drinker = AchievementTask.objects.create( + task_type="product", + product=self.product_beer, + goal_value=1, + ) + + self.achievement_beer_drinker = Achievement.objects.create( + title="Beer Drinker", + description="Drink a Beer", + ) + + self.achievement_beer_drinker.tasks.add(self.task_beer_drinker) + + self.task_better_beer_drinker = AchievementTask.objects.create( + task_type="product", + product=self.product_beer, + goal_value=2, + ) + + self.achievement_better_beer_drinker = Achievement.objects.create( + title="Better Beer Drinker", + description="Drink two Beers", + ) + + self.achievement_better_beer_drinker.tasks.add(self.task_better_beer_drinker) + + self.cph_tz = pytz.timezone("Europe/Copenhagen") + + self.create_sale = lambda: {Sale.objects.create(member=self.member1, product=self.product_beer, price=10)} + + self.create_achievement_complete = lambda a, m=self.member1: { + AchievementComplete.objects.create(member=m, achievement=a) + } + + def test_get_new_achievements_returns_correct_achievement(self): + self.create_sale() + + new_achievements = get_new_achievements(self.member1, self.product_beer) + self.assertIn(self.achievement_beer_drinker, new_achievements) + self.assertNotIn(self.achievement_better_beer_drinker, new_achievements) + + def test_get_new_achievements_constraints(self): + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 12, 12, 1))): + self.create_sale() + + constraint = AchievementConstraint.objects.create( + month_start=5, # Only May + month_end=5, + day_start=12, + day_end=13, + time_start=datetime.time(12, 00), + time_end=datetime.time(13, 00), + ) + + self.achievement_beer_drinker.constraints.add(constraint) + + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 13, 12, 50, 0))): + new_achievements_1 = get_new_achievements(self.member1, self.product_beer) + + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 13, 13, 1, 0))): + new_achievements_2 = get_new_achievements(self.member1, self.product_beer) + + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 14, 12, 50, 0))): + new_achievements_3 = get_new_achievements(self.member1, self.product_beer) + + self.assertIn(self.achievement_beer_drinker, new_achievements_1) + self.assertNotIn(self.achievement_beer_drinker, new_achievements_2) + self.assertNotIn(self.achievement_beer_drinker, new_achievements_3) + + def test_new_achievements_require_all_tasks_and_constraints(self): + + constraint = AchievementConstraint.objects.create( # An AchievementConstraint that covers all days + month_start=5, # Only May + month_end=5, + day_start=12, + day_end=13, + time_start=datetime.time(12, 00), + time_end=datetime.time(13, 00), + ) + + self.achievement_better_beer_drinker.constraints.add(constraint) + + # Is not the correct month + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 4, 12, 12, 1))): + self.create_sale() + + # Is within the achievement constraint + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 12, 12, 1))): + self.create_sale() + + # get_new_achievements is called within the achievement constraint + # (the output should not contain the achievement, as it needs TWO beer sales) + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 13, 12, 50, 0))): + new_achievements_1 = get_new_achievements(self.member1, self.product_beer) + + # Is within the achievement constraint + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 12, 12, 5))): + self.create_sale() + + # get_new_achievements is called within the achievement constraint + # (The output should contain the achievement now) + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 13, 12, 50, 0))): + new_achievements_2 = get_new_achievements(self.member1, self.product_beer) + + self.assertNotIn(self.achievement_better_beer_drinker, new_achievements_1) + self.assertIn(self.achievement_better_beer_drinker, new_achievements_2) + + def test_get_new_achievements_weekday_constraint(self): + constraint = AchievementConstraint.objects.create(weekday=3) # Thursday + self.achievement_beer_drinker.constraints.add(constraint) + + # This day is a Wednesday + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 14))): + self.create_sale() + + # A Thursday (Should not return beer_drinker achievement) + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 15))): + new_achievements_1 = get_new_achievements(self.member1, self.product_beer) + + # This day is a Thursday + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 15))): + self.create_sale() + + # A Thursday (Should return beer_drinker achievement) + with freeze_time(self.cph_tz.localize(datetime.datetime(2025, 5, 15))): + new_achievements_2 = get_new_achievements(self.member1, self.product_beer) + + self.assertNotIn(self.achievement_beer_drinker, new_achievements_1) + self.assertIn(self.achievement_beer_drinker, new_achievements_2) + + def test_get_new_achievements_does_not_return_completed_achievements(self): + AchievementComplete.objects.create(member=self.member1, achievement=self.achievement_beer_drinker) + + self.create_sale() + self.create_sale() + + new_achievements = get_new_achievements(self.member1, self.product_beer) + + self.assertNotIn(self.achievement_beer_drinker, new_achievements) + self.assertIn(self.achievement_better_beer_drinker, new_achievements) + + def test_get_acquired_achievements_returns_correct_achievements(self): + self.create_achievement_complete(self.achievement_beer_drinker) + acquired_achievements_1 = list(get_acquired_achievements_with_rarity(self.member1)) + + self.create_achievement_complete(self.achievement_better_beer_drinker) + acquired_achievements_2 = list(get_acquired_achievements_with_rarity(self.member1)) + + self.assertIn(self.achievement_beer_drinker, [x[0] for x in acquired_achievements_1]) + self.assertNotIn(self.achievement_better_beer_drinker, [x[0] for x in acquired_achievements_1]) + + self.assertIn(self.achievement_beer_drinker, [x[0] for x in acquired_achievements_2]) + self.assertIn(self.achievement_better_beer_drinker, [x[0] for x in acquired_achievements_2]) + + def test_get_missing_achievements_returns_correct_achievements(self): + self.create_achievement_complete(self.achievement_beer_drinker) + acquired_achievements_1 = list(get_missing_achievements(self.member1)) + + self.create_achievement_complete(self.achievement_better_beer_drinker) + acquired_achievements_2 = list(get_missing_achievements(self.member1)) + + self.assertNotIn(self.achievement_beer_drinker, acquired_achievements_1) + self.assertIn(self.achievement_better_beer_drinker, acquired_achievements_1) + + self.assertNotIn(self.achievement_beer_drinker, acquired_achievements_2) + self.assertNotIn(self.achievement_better_beer_drinker, acquired_achievements_2) + + def test_get_user_leaderboard_position_returns_correct_percentage(self): + + # Of all members with achievements, this member has the lowest amount (top 100%) + self.create_achievement_complete(self.achievement_beer_drinker, self.member2) + + # This user has the most achievements, but since only 2 has achievements, he is top 50% + self.create_achievement_complete(self.achievement_beer_drinker, self.member3) + self.create_achievement_complete(self.achievement_better_beer_drinker, self.member3) + + top_percentage_1 = get_user_leaderboard_position(self.member1) + top_percentage_2 = get_user_leaderboard_position(self.member2) + top_percentage_3 = get_user_leaderboard_position(self.member3) + + self.assertEqual(top_percentage_1, 100.0) # A member with no achievements is always top 100% + self.assertEqual(top_percentage_2, 100.0) + self.assertEqual(top_percentage_3, 50.0) diff --git a/stregsystem/views.py b/stregsystem/views.py index 0468dacaf..4ef4ddf26 100644 --- a/stregsystem/views.py +++ b/stregsystem/views.py @@ -1,7 +1,7 @@ import datetime import io import json -from typing import List, Type +from typing import List, Type, Tuple import pytz import qrcode @@ -16,7 +16,7 @@ from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import permission_required from django.core import management -from django.db.models import Q, Count, Sum +from django.db.models import Q, Count, Sum, QuerySet from django.forms import modelformset_factory from django.http import HttpResponsePermanentRedirect, HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404, render, redirect @@ -43,6 +43,9 @@ NamedProduct, ApprovalModel, ProductNote, + Achievement, + AchievementComplete, + AchievementTask, ) from stregsystem.templatetags.stregsystem_extras import money from stregsystem.utils import ( @@ -56,6 +59,12 @@ make_unprocessed_signups_query, ) +from .achievements import ( + get_new_achievements, + get_acquired_achievements_with_rarity, + get_missing_achievements, + get_user_leaderboard_position, +) from .booze import ballmer_peak from .caffeine import caffeine_mg_to_coffee_cups from .forms import PaymentToolForm, QRPaymentForm, PurchaseForm, SignupForm, RankingDateForm, SignupToolForm @@ -228,12 +237,23 @@ def quicksale(request, room, member: Member, bought_ids): member_balance, ) = __set_local_values(member, room, products, order, now) + new_achievements: List[Achievement] = [] + for p, count in Counter(products).most_common(): + new_achievements.extend(get_new_achievements(member, p)) + products = Counter([str(product.name) for product in products]).most_common() + # THIS WAS NOT IN THE ORIGINAL STREGSYSTEM AND I ADDED IT BECAUSE OTHERWISE IT WOULD + # NOT SHOW THE PRODUCTS AFTER USING QUICKBUY + ProductNotePair = namedtuple('ProductNotePair', 'product note') + product_note_pair_list = [ + ProductNotePair(product, __get_active_notes_for_product(product)) for product in __get_productlist(room.id) + ] + return render(request, 'stregsystem/index_sale.html', locals()) -def usermenu(request, room, member, bought, from_sale=False): +def usermenu(request, room, member, bought, new_achievements=[], from_sale=False): negative_balance = member.balance < 0 product_list = __get_productlist(room.id) ProductNotePair = namedtuple('ProductNotePair', 'product note') @@ -247,6 +267,7 @@ def usermenu(request, room, member, bought, from_sale=False): bp_minutes, bp_seconds, ) = ballmer_peak(promille) + MEDIA_ROOT = settings.MEDIA_ROOT caffeine = member.calculate_caffeine_in_body() cups = caffeine_mg_to_coffee_cups(caffeine) @@ -287,6 +308,33 @@ def menu_userinfo(request, room_id, member_id): negative_balance = member.balance < 0 stregforbud = member.has_stregforbud() + acquired_achievements: List[Tuple[Achievement, float]] = get_acquired_achievements_with_rarity(member) + missing_achievements: QuerySet[Achievement] = get_missing_achievements(member) + achievement_progress_str: str = ( + f"{len(acquired_achievements)}/{len(acquired_achievements)+len(missing_achievements)}" + ) + achievement_top_percentage: float = get_user_leaderboard_position(member) + achievement_missing_icon: str = f"{settings.MEDIA_URL}stregsystem/achievement/achievement_missing.png" + + def get_color_by_rarity(rarity): + if rarity <= 1: + color = (243, 175, 25) # Fortnite Orange (Legendary) + elif rarity <= 5: + color = (157, 77, 187) # Fortnite Purple (Epic) + elif rarity <= 10: + color = (76, 81, 247) # Fortnite Blue (Rare) + elif rarity <= 25: + color = (49, 146, 54) # Fortnite Green (Common) + else: + color = (140, 140, 140) # Fortnite Green (Uncommon) + return f"rgb{color}" + + # Convert the acquired achievements to a list of tuples with rounded rarity and color + acquired_achievements = [ + (achievement, f"{round(rarity, 2)}%", get_color_by_rarity(rarity)) + for achievement, rarity in acquired_achievements + ] + return render(request, 'stregsystem/menu_userinfo.html', locals()) @@ -426,6 +474,7 @@ def menu_sale(request, room_id, member_id, product_id=None): room = Room.objects.get(pk=room_id) news = __get_news() member = Member.objects.get(pk=member_id, active=True) + new_achievements = [] if not member.signup_due_paid: return render(request, 'stregsystem/error_signupdue.html', locals()) @@ -453,6 +502,8 @@ def menu_sale(request, room_id, member_id, product_id=None): order.execute() + new_achievements = get_new_achievements(member, product) + except Product.DoesNotExist: pass except StregForbudError: @@ -463,7 +514,7 @@ def menu_sale(request, room_id, member_id, product_id=None): # Refresh member, to get new amount member = Member.objects.get(pk=member_id, active=True) - return usermenu(request, room, member, product, from_sale=True) + return usermenu(request, room, member, product, new_achievements, from_sale=True) @staff_member_required()