feat(market): add draft status and multiplier

This commit is contained in:
Loïc Gremaud 2026-05-23 20:54:19 +02:00
parent 79e7cef3ba
commit e557fe2cda
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
14 changed files with 121 additions and 46 deletions

View File

@ -23,16 +23,29 @@ class MarketAdmin(admin.ModelAdmin):
inlines = [MarketOptionInline] inlines = [MarketOptionInline]
fieldsets = ( fieldsets = (
("Info", {"fields": ["uuid", "title", "description"]}), ("Info", {"fields": ["uuid", "title", "description"]}),
("Configuration", {"fields": ["end_date"]}), ("Configuration", {"fields": ["end_date", "multiplier"]}),
("Status", {"fields": ["status", "winning_option"]}), ("Status", {"fields": ["status", "winning_option"]}),
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}), ("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
) )
def has_change_permission(self, request, obj=None):
# Prevent any changes to resolved markets
if obj and obj.status == Market.Status.RESOLVED:
return False
return super().has_change_permission(request, obj)
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if not change: # Creating new market if not change: # Creating new market
obj.created_by = request.user obj.created_by = request.user
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
@admin.action(description="Publish selected draft markets")
def publish_markets(self, request, queryset):
updated = queryset.filter(status=Market.Status.DRAFT).update(
status=Market.Status.OPEN
)
self.message_user(request, f"Published {updated} market(s).")
@admin.action(description="Close selected markets") @admin.action(description="Close selected markets")
def close_markets(self, request, queryset): def close_markets(self, request, queryset):
updated = queryset.filter(status=Market.Status.OPEN).update( updated = queryset.filter(status=Market.Status.OPEN).update(
@ -40,7 +53,7 @@ class MarketAdmin(admin.ModelAdmin):
) )
self.message_user(request, f"Closed {updated} market(s).") self.message_user(request, f"Closed {updated} market(s).")
actions = ["close_markets"] actions = ["publish_markets", "close_markets"]
@admin.register(MarketOption) @admin.register(MarketOption)

View File

@ -20,8 +20,8 @@ router = Router(tags=["market"])
@router.get("/", response=List[MarketListSchema]) @router.get("/", response=List[MarketListSchema])
def list_markets(request): def list_markets(request):
"""List all markets.""" """List all markets (excludes draft markets)."""
markets = Market.objects.all() markets = Market.objects.exclude(status=Market.Status.DRAFT)
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc # Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
options_queryset = MarketOption.objects.annotate( options_queryset = MarketOption.objects.annotate(
total_bets=Coalesce(Sum("user_bets__amount"), 0) total_bets=Coalesce(Sum("user_bets__amount"), 0)
@ -52,7 +52,7 @@ def close_market(request, market_uuid: str):
market = get_object_or_404(Market, uuid=market_uuid) market = get_object_or_404(Market, uuid=market_uuid)
market.status = Market.Status.CLOSED market.status = Market.Status.CLOSED
market.save(update_fields=["status", "updated_at"]) market.save(update_fields=["status", "updated_at"])
return {"status": "closed"} return {"status": Market.Status.CLOSED}
@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema) @router.post("/{market_uuid}/actions/resolve", response=MarketListSchema)
@ -91,10 +91,12 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
users_to_update = [] users_to_update = []
with transaction.atomic(): with transaction.atomic():
# Award payouts to winners # Award payouts to winners with multiplier
if total_winning > 0: if total_winning > 0:
for bet in winning_bets: for bet in winning_bets:
payout = round(bet.amount / total_winning * total_pot) payout = round(
bet.amount / total_winning * total_pot * market.multiplier
)
bet.user.points += payout bet.user.points += payout
users_to_update.append(bet.user) users_to_update.append(bet.user)
point_changes.append( point_changes.append(

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-23 18:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0004_alter_marketoption_options_and_more"),
]
operations = [
migrations.AddField(
model_name="market",
name="multiplier",
field=models.FloatField(default=1.0),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 5.2.7 on 2026-05-23 18:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0005_market_multiplier"),
]
operations = [
migrations.AlterField(
model_name="market",
name="status",
field=models.CharField(
choices=[
("draft", "Draft"),
("open", "Open"),
("closed", "Closed"),
("resolved", "Resolved"),
],
default="draft",
max_length=10,
),
),
]

View File

@ -13,6 +13,7 @@ class BaseModel(models.Model):
class Market(BaseModel): class Market(BaseModel):
class Status(models.TextChoices): class Status(models.TextChoices):
DRAFT = "draft", "Draft"
OPEN = "open", "Open" OPEN = "open", "Open"
CLOSED = "closed", "Closed" CLOSED = "closed", "Closed"
RESOLVED = "resolved", "Resolved" RESOLVED = "resolved", "Resolved"
@ -20,9 +21,10 @@ class Market(BaseModel):
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
description = models.TextField(blank=True) description = models.TextField(blank=True)
status = models.CharField( status = models.CharField(
max_length=10, choices=Status.choices, default=Status.OPEN max_length=10, choices=Status.choices, default=Status.DRAFT
) )
end_date = models.DateTimeField() end_date = models.DateTimeField()
multiplier = models.FloatField(default=1.0)
created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT) created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT)
winning_option = models.ForeignKey( winning_option = models.ForeignKey(
"MarketOption", "MarketOption",

View File

@ -21,6 +21,7 @@ class MarketListSchema(Schema):
description: str description: str
status: str status: str
end_date: datetime end_date: datetime
multiplier: float = 1.0
created_at: datetime created_at: datetime
options: List[MarketOptionSchema] options: List[MarketOptionSchema]
winning_option: Optional[MarketOptionSchema] = None winning_option: Optional[MarketOptionSchema] = None

View File

@ -67,20 +67,10 @@ def get_user_info(request):
user = request.user user = request.user
if user.is_authenticated: if user.is_authenticated:
return { return user
"id": user.id,
"username": user.username, return {
"first_name": user.first_name, "is_authenticated": False,
"last_name": user.last_name, "is_staff": False,
"email": user.email, "is_superuser": False,
"is_authenticated": True, }
"is_staff": user.is_staff,
"is_superuser": user.is_superuser,
"cas_groups": getattr(user, "cas_groups", []),
}
else:
return {
"is_authenticated": False,
"is_staff": False,
"is_superuser": False,
}

View File

@ -77,19 +77,31 @@ onMounted(async () => {
<div class="space-y-8"> <div class="space-y-8">
<!-- My Bets Section --> <!-- My Bets Section -->
<div v-if="userInfo?.is_authenticated"> <div v-if="userInfo?.is_authenticated">
<h3 class="text-2xl font-bold mb-4 flex items-center gap-2"> <div class="flex justify-between items-center mb-4">
<i class="mdi mdi-heart text-error"></i> <h3 class="text-2xl font-bold flex items-center gap-2">
My Bets <i class="mdi mdi-heart text-error"></i>
</h3> My Bets
</h3>
<div class="text-lg font-semibold">
<span class="text-primary">{{ userInfo.points }}</span>
<span class="text-base-content/60 ml-1">pts</span>
</div>
</div>
<UserBets :markets="markets" @refresh="reloadPage" /> <UserBets :markets="markets" @refresh="reloadPage" />
</div> </div>
<!-- All Markets Section --> <!-- All Markets Section -->
<div> <div>
<h3 class="text-2xl font-bold mb-4 flex items-center gap-2"> <div class="flex justify-between items-center mb-4">
<i class="mdi mdi-list"></i> <h3 class="text-2xl font-bold flex items-center gap-2">
All Markets <i class="mdi mdi-list"></i>
</h3> All Markets
</h3>
<a v-if="userInfo?.is_superuser" href="/admin/market/market/add/" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus"></i>
Create Market
</a>
</div>
<div v-if="markets.length === 0" class="alert"> <div v-if="markets.length === 0" class="alert">
<i class="mdi mdi-information mr-2"></i> <i class="mdi mdi-information mr-2"></i>
@ -97,12 +109,7 @@ onMounted(async () => {
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<MarketCard <MarketCard v-for="market in markets" :key="market.uuid" :market="market" @refresh="reloadPage" />
v-for="market in markets"
:key="market.uuid"
:market="market"
@refresh="reloadPage"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -194,7 +194,7 @@ export const gamesApiListGames = <ThrowOnError extends boolean = false>(options?
/** /**
* List Markets * List Markets
* *
* List all markets. * List all markets (excludes draft markets).
*/ */
export const marketApiListMarkets = <ThrowOnError extends boolean = false>(options?: Options<MarketApiListMarketsData, ThrowOnError>) => (options?.client ?? client).get<MarketApiListMarketsResponses, unknown, ThrowOnError>({ url: '/api/market/', ...options }); export const marketApiListMarkets = <ThrowOnError extends boolean = false>(options?: Options<MarketApiListMarketsData, ThrowOnError>) => (options?.client ?? client).get<MarketApiListMarketsResponses, unknown, ThrowOnError>({ url: '/api/market/', ...options });

View File

@ -30,6 +30,10 @@ export type UserInfoOut = {
* Email * Email
*/ */
email?: string | null; email?: string | null;
/**
* Points
*/
points?: number;
/** /**
* Is Authenticated * Is Authenticated
*/ */
@ -986,6 +990,10 @@ export type MarketListSchema = {
* End Date * End Date
*/ */
end_date: string; end_date: string;
/**
* Multiplier
*/
multiplier?: number;
/** /**
* Created At * Created At
*/ */

View File

@ -41,6 +41,8 @@ const timeRemaining = computed(() => {
const statusColor = computed(() => { const statusColor = computed(() => {
switch (props.market.status) { switch (props.market.status) {
case "draft":
return "badge-secondary";
case "open": case "open":
return "badge-success"; return "badge-success";
case "closed": case "closed":
@ -68,7 +70,7 @@ const getMultiplier = (option: MarketOption) => {
const getPotentialGain = (option: MarketOption) => { const getPotentialGain = (option: MarketOption) => {
if (!existingBet.value || existingBet.value.option.uuid !== option.uuid) return 0; if (!existingBet.value || existingBet.value.option.uuid !== option.uuid) return 0;
const multiplier = getMultiplier(option); const multiplier = getMultiplier(option);
return Math.round(existingBet.value.amount * multiplier); return Math.round(existingBet.value.amount * multiplier * props.market.multiplier);
}; };
const closeMarket = async () => { const closeMarket = async () => {
@ -194,10 +196,15 @@ onMounted(async () => {
<p class="text-sm text-base-content/70">{{ market.description }}</p> <p class="text-sm text-base-content/70">{{ market.description }}</p>
</div> </div>
<div class="flex flex-col items-end gap-2"> <div class="flex flex-col items-end gap-2">
<div :class="['badge', statusColor, 'text-white']"> <div class="flex gap-2 items-center">
{{ market.status }} <div :class="['badge', statusColor, 'text-white']">
{{ market.status }}
</div>
<div v-if="market.multiplier > 1" class="badge badge-accent text-white font-bold">
{{ market.multiplier }}x
</div>
</div> </div>
<div v-if="market.status === 'open'" class="text-sm text-base-content/60 text-right"> <div v-if="market.status === 'open' || market.status === 'closed'" class="text-sm text-base-content/60 text-right">
<div>{{ timeRemaining }}</div> <div>{{ timeRemaining }}</div>
<div class="text-xs">until close</div> <div class="text-xs">until close</div>
</div> </div>

View File

@ -18,7 +18,7 @@ const winningBets = computed(() => {
return userBets.value.filter(bet => { return userBets.value.filter(bet => {
const market = bet.market; const market = bet.market;
return market?.status === "resolved" && market?.winning_option?.uuid === bet.option.uuid; return market?.status === "resolved" && market?.winning_option?.uuid === bet.option.uuid;
}); }).reverse();
}); });
const losingBets = computed(() => { const losingBets = computed(() => {

View File

@ -180,8 +180,9 @@ export interface Market {
uuid: string uuid: string
title: string title: string
description: string description: string
status: 'open' | 'closed' | 'resolved' status: 'draft' | 'open' | 'closed' | 'resolved'
end_date: string end_date: string
multiplier: number
created_at: string created_at: string
options: MarketOption[] options: MarketOption[]
winning_option?: MarketOption | null winning_option?: MarketOption | null

View File

@ -221,6 +221,7 @@ class UserInfoOut(Schema):
first_name: Optional[str] = None first_name: Optional[str] = None
last_name: Optional[str] = None last_name: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
points: int = 0
is_authenticated: bool is_authenticated: bool
is_staff: bool is_staff: bool
is_superuser: bool is_superuser: bool