feat(market): add draft status and multiplier
This commit is contained in:
parent
79e7cef3ba
commit
e557fe2cda
@ -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)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user