feat(market): track user points change

This commit is contained in:
Loïc Gremaud 2026-05-23 20:26:59 +02:00
parent 42e3571fab
commit a264336bd8
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
14 changed files with 339 additions and 48 deletions

View File

@ -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

View File

@ -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),
),
]

View File

@ -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})"

View File

@ -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

View File

@ -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

View File

@ -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",
)
],
},
),
]

View File

@ -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",
),
]

View File

@ -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"
),
),
]

View File

@ -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}"

View File

@ -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

View File

@ -1,2 +1 @@
# Create your tests here.

View File

@ -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;
};
/**

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { polylanSubmitterApiGetUserInfo, marketApiCreateBet, marketApiListUserBets } from "../api";
import type { Market } from "../types";
import type { Market, MarketOption } from "../types";
import type { UserInfoOut, UserBetSchema } from "../api/types.gen";
const props = defineProps<{
@ -53,6 +53,21 @@ const canBet = computed(() => {
return props.market.status === "open" && userInfo.value?.is_authenticated && selectedOption.value && betAmount.value > 0;
});
const totalPot = computed(() => {
return props.market.options.reduce((sum, opt) => sum + opt.total_bets, 0);
});
const getMultiplier = (option: MarketOption) => {
if (option.total_bets === 0) return 0;
return totalPot.value / option.total_bets;
};
const getPotentialGain = (option: MarketOption) => {
if (!existingBet.value || existingBet.value.option.uuid !== option.uuid) return 0;
const multiplier = getMultiplier(option);
return Math.round(existingBet.value.amount * multiplier);
};
const placeBet = async () => {
if (!selectedOption.value || !betAmount.value) return;
@ -75,7 +90,14 @@ const placeBet = async () => {
await loadUserBets();
betAmount.value = 0;
} else {
error.value = String(response.error) || "Failed to place bet";
const err = response.error as any;
if (typeof err === 'object' && err?.detail) {
error.value = err.detail;
} else if (typeof err === 'string') {
error.value = err;
} else {
error.value = "Failed to place bet";
}
}
} catch (e) {
error.value = "Error placing bet";
@ -145,10 +167,18 @@ onMounted(async () => {
]">
<div class="flex flex-col gap-2 flex-1">
<span class="label-text font-medium">{{ option.text }}</span>
<span v-if="existingBet && existingBet.option.uuid === option.uuid"
class="badge badge-primary badge-sm w-fit">
Your bet: {{ existingBet.amount }} pts
</span>
<div class="text-xs text-base-content/60">
<div>Pool: {{ option.total_bets }} pts</div>
<div v-if="option.total_bets > 0">Multiplier: {{ getMultiplier(option).toFixed(2) }}x</div>
</div>
<div v-if="existingBet && existingBet.option.uuid === option.uuid" class="flex flex-col gap-1">
<span class="badge badge-primary badge-sm w-fit">
Your bet: {{ existingBet.amount }} pts
</span>
<span v-if="option.total_bets > 0" class="badge badge-success badge-sm w-fit">
Potential: {{ getPotentialGain(option) }} pts
</span>
</div>
</div>
<input type="radio" :value="option.uuid" v-model="selectedOption" class="radio radio-primary"
:disabled="market.status !== 'open' || !!(existingBet && existingBet.option.uuid !== option.uuid)" />

View File

@ -173,14 +173,13 @@ export interface TournamentPuzzleResults {
export interface MarketOption {
uuid: string
text: string
position: number
total_bets: number
}
export interface Market {
uuid: string
title: string
description: string
type: 'yes_no' | 'multiple'
status: 'open' | 'closed' | 'resolved'
end_date: string
created_at: string