From a264336bd832942a5dc131b9b2e6d664aa96d931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Sat, 23 May 2026 20:26:59 +0200 Subject: [PATCH] feat(market): track user points change --- polylan_submitter/accounts/admin.py | 3 + .../migrations/0002_customuser_points.py | 17 +++ polylan_submitter/accounts/models.py | 3 + polylan_submitter/market/admin.py | 28 +++-- polylan_submitter/market/api.py | 101 ++++++++++++++++-- .../market/migrations/0002_userpointchange.py | 73 +++++++++++++ .../migrations/0003_remove_market_type.py | 16 +++ ...004_alter_marketoption_options_and_more.py | 40 +++++++ polylan_submitter/market/models.py | 39 +++++-- polylan_submitter/market/schemas.py | 13 ++- polylan_submitter/market/tests.py | 1 - polylan_submitter/src/api/types.gen.ts | 8 +- .../src/components/MarketCard.vue | 42 ++++++-- polylan_submitter/src/types/index.ts | 3 +- 14 files changed, 339 insertions(+), 48 deletions(-) create mode 100644 polylan_submitter/accounts/migrations/0002_customuser_points.py create mode 100644 polylan_submitter/market/migrations/0002_userpointchange.py create mode 100644 polylan_submitter/market/migrations/0003_remove_market_type.py create mode 100644 polylan_submitter/market/migrations/0004_alter_marketoption_options_and_more.py diff --git a/polylan_submitter/accounts/admin.py b/polylan_submitter/accounts/admin.py index e13bf0d..fb854bc 100644 --- a/polylan_submitter/accounts/admin.py +++ b/polylan_submitter/accounts/admin.py @@ -34,3 +34,6 @@ class CustomUserAdmin(UserAdmin): return obj.get_cas_groups_display() get_cas_groups_display.short_description = "CAS Groups" + + def has_delete_permission(self, request, obj=None): + return False diff --git a/polylan_submitter/accounts/migrations/0002_customuser_points.py b/polylan_submitter/accounts/migrations/0002_customuser_points.py new file mode 100644 index 0000000..842eff2 --- /dev/null +++ b/polylan_submitter/accounts/migrations/0002_customuser_points.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2026-05-23 18:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="customuser", + name="points", + field=models.IntegerField(default=1000), + ), + ] diff --git a/polylan_submitter/accounts/models.py b/polylan_submitter/accounts/models.py index b0abfda..3a014ef 100644 --- a/polylan_submitter/accounts/models.py +++ b/polylan_submitter/accounts/models.py @@ -14,6 +14,9 @@ class CustomUser(AbstractUser): # Additional fields that might come from CAS cas_attributes = models.JSONField(default=dict, blank=True) + # Market points balance + points = models.IntegerField(default=1000) + def __str__(self): return f"{self.username} ({self.cas_user_id})" diff --git a/polylan_submitter/market/admin.py b/polylan_submitter/market/admin.py index 68d29cc..ed40306 100644 --- a/polylan_submitter/market/admin.py +++ b/polylan_submitter/market/admin.py @@ -1,17 +1,17 @@ from django.contrib import admin -from market.models import Market, MarketOption, UserBet +from market.models import Market, MarketOption, UserBet, UserPointChange class MarketOptionInline(admin.TabularInline): model = MarketOption extra = 1 - fields = ["text", "position"] + fields = ["text"] @admin.register(Market) class MarketAdmin(admin.ModelAdmin): - list_display = ["title", "type", "status", "end_date", "created_by", "created_at"] - list_filter = ["status", "type", "created_at"] + list_display = ["title", "status", "end_date", "created_by", "created_at"] + list_filter = ["status", "created_at"] search_fields = ["uuid", "title"] readonly_fields = [ "uuid", @@ -23,7 +23,7 @@ class MarketAdmin(admin.ModelAdmin): inlines = [MarketOptionInline] fieldsets = ( ("Info", {"fields": ["uuid", "title", "description"]}), - ("Configuration", {"fields": ["type", "end_date"]}), + ("Configuration", {"fields": ["end_date"]}), ("Status", {"fields": ["status", "winning_option"]}), ("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}), ) @@ -45,7 +45,7 @@ class MarketAdmin(admin.ModelAdmin): @admin.register(MarketOption) class MarketOptionAdmin(admin.ModelAdmin): - list_display = ["text", "market", "position"] + list_display = ["text", "market"] list_filter = ["market"] search_fields = ["uuid", "text", "market__title"] readonly_fields = ["uuid"] @@ -54,7 +54,7 @@ class MarketOptionAdmin(admin.ModelAdmin): @admin.register(UserBet) class UserBetAdmin(admin.ModelAdmin): list_display = ["user", "option", "amount", "created_at"] - list_filter = ["created_at", "option__market"] + list_filter = ["user", "created_at", "option__market"] search_fields = ["uuid", "user__username", "option__text"] readonly_fields = ["uuid", "user", "option", "amount", "created_at", "updated_at"] @@ -63,3 +63,17 @@ class UserBetAdmin(admin.ModelAdmin): def has_delete_permission(self, request, obj=None): return False + + +@admin.register(UserPointChange) +class UserPointChangeAdmin(admin.ModelAdmin): + list_display = ["user", "market", "amount", "reason", "created_at"] + list_filter = ["user", "reason", "created_at", "market"] + search_fields = ["uuid", "user__username", "market__title"] + readonly_fields = ["uuid", "created_at", "updated_at"] + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/polylan_submitter/market/api.py b/polylan_submitter/market/api.py index 88175f3..89e1c07 100644 --- a/polylan_submitter/market/api.py +++ b/polylan_submitter/market/api.py @@ -2,8 +2,11 @@ from typing import List from ninja import Router from ninja.errors import HttpError from django.shortcuts import get_object_or_404 +from django.db.models import Sum, Prefetch +from django.db.models.functions import Coalesce +from django.db import transaction -from market.models import Market, MarketOption, UserBet +from market.models import Market, MarketOption, UserBet, UserPointChange from market.schemas import ( MarketListSchema, ResolveMarketSchema, @@ -18,7 +21,13 @@ router = Router(tags=["market"]) @router.get("/", response=List[MarketListSchema]) def list_markets(request): """List all markets.""" - return Market.objects.prefetch_related("options").all() + markets = Market.objects.all() + # Prefetch options with total_bets annotation sorted by total_bets desc, then text asc + options_queryset = MarketOption.objects.annotate( + total_bets=Coalesce(Sum("user_bets__amount"), 0) + ).order_by("-total_bets", "text") + + return markets.prefetch_related(Prefetch("options", queryset=options_queryset)) @router.get("/user/bets", response=List[UserBetSchema]) @@ -27,9 +36,11 @@ def list_user_bets(request): if not request.user.is_authenticated: raise HttpError(401, "Authentication required") - return UserBet.objects.filter(user=request.user).select_related( - "option__market" - ).prefetch_related("option__market__options") + return ( + UserBet.objects.filter(user=request.user) + .select_related("option__market") + .prefetch_related("option__market__options") + ) @router.post("/{market_uuid}/actions/close") @@ -59,6 +70,60 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema): market.winning_option = winning_option market.status = Market.Status.RESOLVED market.save(update_fields=["winning_option", "status", "updated_at"]) + + # Calculate and distribute winnings + all_bets = list( + UserBet.objects.filter(option__market=market).select_related("user") + ) + + # Calculate total pot + total_pot = sum(bet.amount for bet in all_bets) + if total_pot == 0: + return market + + # Separate winning and losing bets + winning_bets = [bet for bet in all_bets if bet.option_id == winning_option.id] + losing_bets = [bet for bet in all_bets if bet.option_id != winning_option.id] + + total_winning = sum(bet.amount for bet in winning_bets) + + point_changes = [] + users_to_update = [] + + with transaction.atomic(): + # Award payouts to winners + if total_winning > 0: + for bet in winning_bets: + payout = round(bet.amount / total_winning * total_pot) + bet.user.points += payout + users_to_update.append(bet.user) + point_changes.append( + UserPointChange( + user=bet.user, + market=market, + amount=payout, + reason=UserPointChange.Reason.BET_WON, + ) + ) + + # Record losing bets (points already deducted) + for bet in losing_bets: + point_changes.append( + UserPointChange( + user=bet.user, + market=market, + amount=-bet.amount, + reason=UserPointChange.Reason.BET_LOST, + ) + ) + + # Bulk update users + for user in users_to_update: + user.save(update_fields=["points"]) + + # Bulk create point changes + UserPointChange.objects.bulk_create(point_changes) + return market @@ -78,10 +143,11 @@ def create_bet(request, market_uuid: str, payload: UserBetCreateSchema): raise HttpError(400, "Market is not open for betting") # Check if user already has a bet on a different option in this market - existing_bet_on_market = UserBet.objects.filter( - user=request.user, - option__market=market - ).exclude(option=option).first() + existing_bet_on_market = ( + UserBet.objects.filter(user=request.user, option__market=market) + .exclude(option=option) + .first() + ) if existing_bet_on_market: raise HttpError(400, "You can only bet on one option per market") @@ -90,10 +156,27 @@ def create_bet(request, market_uuid: str, payload: UserBetCreateSchema): if existing_bet and payload.amount < existing_bet.amount: raise HttpError(400, "Cannot decrease bet amount. You can only increase it.") + # Calculate delta (amount to deduct from user's points) + delta = payload.amount - (existing_bet.amount if existing_bet else 0) + + # Check if user has enough points + if request.user.points < delta: + raise HttpError(400, "Insufficient points for this bet") + user_bet, created = UserBet.objects.update_or_create( user=request.user, option=option, defaults={"amount": payload.amount}, ) + # Deduct points and record the change + request.user.points -= delta + request.user.save(update_fields=["points"]) + UserPointChange.objects.create( + user=request.user, + market=market, + amount=-delta, + reason=UserPointChange.Reason.BET_PLACED, + ) + return user_bet diff --git a/polylan_submitter/market/migrations/0002_userpointchange.py b/polylan_submitter/market/migrations/0002_userpointchange.py new file mode 100644 index 0000000..f0a25dd --- /dev/null +++ b/polylan_submitter/market/migrations/0002_userpointchange.py @@ -0,0 +1,73 @@ +# Generated by Django 5.2.7 on 2026-05-23 18:11 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("market", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserPointChange", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("amount", models.IntegerField()), + ( + "reason", + models.CharField( + choices=[ + ("bet_placed", "Bet Placed"), + ("bet_won", "Bet Won"), + ("bet_lost", "Bet Lost"), + ], + max_length=20, + ), + ), + ( + "market", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="point_changes", + to="market.market", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="point_changes", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + "indexes": [ + models.Index( + fields=["user", "-created_at"], + name="market_user_user_id_631ba9_idx", + ) + ], + }, + ), + ] diff --git a/polylan_submitter/market/migrations/0003_remove_market_type.py b/polylan_submitter/market/migrations/0003_remove_market_type.py new file mode 100644 index 0000000..c44599f --- /dev/null +++ b/polylan_submitter/market/migrations/0003_remove_market_type.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.7 on 2026-05-23 18:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("market", "0002_userpointchange"), + ] + + operations = [ + migrations.RemoveField( + model_name="market", + name="type", + ), + ] diff --git a/polylan_submitter/market/migrations/0004_alter_marketoption_options_and_more.py b/polylan_submitter/market/migrations/0004_alter_marketoption_options_and_more.py new file mode 100644 index 0000000..58f92d9 --- /dev/null +++ b/polylan_submitter/market/migrations/0004_alter_marketoption_options_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.7 on 2026-05-23 18:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("market", "0003_remove_market_type"), + ] + + operations = [ + migrations.AlterModelOptions( + name="marketoption", + options={"ordering": ["text"]}, + ), + migrations.RemoveConstraint( + model_name="marketoption", + name="unique_market_option_position", + ), + migrations.RemoveIndex( + model_name="marketoption", + name="market_mark_market__8679ce_idx", + ), + migrations.RemoveField( + model_name="marketoption", + name="position", + ), + migrations.AddIndex( + model_name="marketoption", + index=models.Index( + fields=["market"], name="market_mark_market__67f63b_idx" + ), + ), + migrations.AddConstraint( + model_name="marketoption", + constraint=models.UniqueConstraint( + fields=("market", "text"), name="unique_market_option_text" + ), + ), + ] diff --git a/polylan_submitter/market/models.py b/polylan_submitter/market/models.py index 0030fb8..574315e 100644 --- a/polylan_submitter/market/models.py +++ b/polylan_submitter/market/models.py @@ -12,10 +12,6 @@ class BaseModel(models.Model): class Market(BaseModel): - class Type(models.TextChoices): - YES_NO = "yes_no", "Yes/No" - MULTIPLE = "multiple", "Multiple Choice" - class Status(models.TextChoices): OPEN = "open", "Open" CLOSED = "closed", "Closed" @@ -23,7 +19,6 @@ class Market(BaseModel): title = models.CharField(max_length=255) description = models.TextField(blank=True) - type = models.CharField(max_length=10, choices=Type.choices, default=Type.YES_NO) status = models.CharField( max_length=10, choices=Status.choices, default=Status.OPEN ) @@ -51,18 +46,17 @@ class Market(BaseModel): class MarketOption(BaseModel): market = models.ForeignKey(Market, on_delete=models.CASCADE, related_name="options") text = models.CharField(max_length=255) - position = models.PositiveIntegerField(default=0) class Meta: - ordering = ["position"] + ordering = ["text"] constraints = [ models.UniqueConstraint( - fields=["market", "position"], - name="unique_market_option_position", + fields=["market", "text"], + name="unique_market_option_text", ), ] indexes = [ - models.Index(fields=["market", "position"]), + models.Index(fields=["market"]), ] def __str__(self): @@ -91,3 +85,28 @@ class UserBet(BaseModel): def __str__(self): return f"{self.user.username} bet {self.amount} on {self.option.text}" + + +class UserPointChange(BaseModel): + class Reason(models.TextChoices): + BET_PLACED = "bet_placed", "Bet Placed" + BET_WON = "bet_won", "Bet Won" + BET_LOST = "bet_lost", "Bet Lost" + + user = models.ForeignKey( + "accounts.CustomUser", on_delete=models.CASCADE, related_name="point_changes" + ) + market = models.ForeignKey( + Market, on_delete=models.CASCADE, related_name="point_changes" + ) + amount = models.IntegerField() + reason = models.CharField(max_length=20, choices=Reason.choices) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["user", "-created_at"]), + ] + + def __str__(self): + return f"{self.user.username} {self.reason}: {self.amount} pts on {self.market.title}" diff --git a/polylan_submitter/market/schemas.py b/polylan_submitter/market/schemas.py index 2c387d2..1d49f0e 100644 --- a/polylan_submitter/market/schemas.py +++ b/polylan_submitter/market/schemas.py @@ -8,9 +8,9 @@ from pydantic import field_serializer, model_validator class MarketOptionSchema(Schema): uuid: UUID text: str - position: int + total_bets: int = 0 - @field_serializer('uuid') + @field_serializer("uuid") def serialize_uuid(self, value: UUID) -> str: return str(value) @@ -19,14 +19,13 @@ class MarketListSchema(Schema): uuid: UUID title: str description: str - type: str status: str end_date: datetime created_at: datetime options: List[MarketOptionSchema] winning_option: Optional[MarketOptionSchema] = None - @field_serializer('uuid') + @field_serializer("uuid") def serialize_uuid(self, value: UUID) -> str: return str(value) @@ -47,13 +46,13 @@ class UserBetSchema(Schema): option: MarketOptionSchema market: Optional[MarketListSchema] = None - @field_serializer('uuid') + @field_serializer("uuid") def serialize_uuid(self, value: UUID) -> str: return str(value) - @model_validator(mode='before') + @model_validator(mode="before") @classmethod def resolve_market_from_option(cls, data: Any) -> Any: - if hasattr(data, 'option') and hasattr(data.option, 'market'): + if hasattr(data, "option") and hasattr(data.option, "market"): data.market = data.option.market return data diff --git a/polylan_submitter/market/tests.py b/polylan_submitter/market/tests.py index 4929020..a39b155 100644 --- a/polylan_submitter/market/tests.py +++ b/polylan_submitter/market/tests.py @@ -1,2 +1 @@ - # Create your tests here. diff --git a/polylan_submitter/src/api/types.gen.ts b/polylan_submitter/src/api/types.gen.ts index 20afd50..35bffdb 100644 --- a/polylan_submitter/src/api/types.gen.ts +++ b/polylan_submitter/src/api/types.gen.ts @@ -978,10 +978,6 @@ export type MarketListSchema = { * Description */ description: string; - /** - * Type - */ - type: string; /** * Status */ @@ -1014,9 +1010,9 @@ export type MarketOptionSchema = { */ text: string; /** - * Position + * Total Bets */ - position: number; + total_bets?: number; }; /** diff --git a/polylan_submitter/src/components/MarketCard.vue b/polylan_submitter/src/components/MarketCard.vue index 01c879c..4c01371 100644 --- a/polylan_submitter/src/components/MarketCard.vue +++ b/polylan_submitter/src/components/MarketCard.vue @@ -1,7 +1,7 @@