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: {}', 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 %}
+
+
+
You are Top: {{ achievement_top_percentage }}%
+