diff --git a/polylan_submitter/market/admin.py b/polylan_submitter/market/admin.py index 8c38f3f..68d29cc 100644 --- a/polylan_submitter/market/admin.py +++ b/polylan_submitter/market/admin.py @@ -1,3 +1,65 @@ from django.contrib import admin +from market.models import Market, MarketOption, UserBet -# Register your models here. + +class MarketOptionInline(admin.TabularInline): + model = MarketOption + extra = 1 + fields = ["text", "position"] + + +@admin.register(Market) +class MarketAdmin(admin.ModelAdmin): + list_display = ["title", "type", "status", "end_date", "created_by", "created_at"] + list_filter = ["status", "type", "created_at"] + search_fields = ["uuid", "title"] + readonly_fields = [ + "uuid", + "created_at", + "updated_at", + "created_by", + "winning_option", + ] + inlines = [MarketOptionInline] + fieldsets = ( + ("Info", {"fields": ["uuid", "title", "description"]}), + ("Configuration", {"fields": ["type", "end_date"]}), + ("Status", {"fields": ["status", "winning_option"]}), + ("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}), + ) + + def save_model(self, request, obj, form, change): + if not change: # Creating new market + obj.created_by = request.user + super().save_model(request, obj, form, change) + + @admin.action(description="Close selected markets") + def close_markets(self, request, queryset): + updated = queryset.filter(status=Market.Status.OPEN).update( + status=Market.Status.CLOSED + ) + self.message_user(request, f"Closed {updated} market(s).") + + actions = ["close_markets"] + + +@admin.register(MarketOption) +class MarketOptionAdmin(admin.ModelAdmin): + list_display = ["text", "market", "position"] + list_filter = ["market"] + search_fields = ["uuid", "text", "market__title"] + readonly_fields = ["uuid"] + + +@admin.register(UserBet) +class UserBetAdmin(admin.ModelAdmin): + list_display = ["user", "option", "amount", "created_at"] + list_filter = ["created_at", "option__market"] + search_fields = ["uuid", "user__username", "option__text"] + readonly_fields = ["uuid", "user", "option", "amount", "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 new file mode 100644 index 0000000..88175f3 --- /dev/null +++ b/polylan_submitter/market/api.py @@ -0,0 +1,99 @@ +from typing import List +from ninja import Router +from ninja.errors import HttpError +from django.shortcuts import get_object_or_404 + +from market.models import Market, MarketOption, UserBet +from market.schemas import ( + MarketListSchema, + ResolveMarketSchema, + UserBetCreateSchema, + UserBetSchema, +) + + +router = Router(tags=["market"]) + + +@router.get("/", response=List[MarketListSchema]) +def list_markets(request): + """List all markets.""" + return Market.objects.prefetch_related("options").all() + + +@router.get("/user/bets", response=List[UserBetSchema]) +def list_user_bets(request): + """List all bets placed by the current user.""" + 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") + + +@router.post("/{market_uuid}/actions/close") +def close_market(request, market_uuid: str): + """Close a market. Admin only.""" + if not request.user.is_staff: + raise HttpError(403, "Permission denied") + + market = get_object_or_404(Market, uuid=market_uuid) + market.status = Market.Status.CLOSED + market.save(update_fields=["status", "updated_at"]) + return {"status": "closed"} + + +@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema) +def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema): + """Resolve a market with a winning option. Admin only.""" + if not request.user.is_staff: + raise HttpError(403, "Permission denied") + + market = get_object_or_404(Market, uuid=market_uuid) + winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid) + + if winning_option.market_id != market.id: + raise HttpError(400, "Option does not belong to this market") + + market.winning_option = winning_option + market.status = Market.Status.RESOLVED + market.save(update_fields=["winning_option", "status", "updated_at"]) + return market + + +@router.post("/{market_uuid}/bets", response=UserBetSchema) +def create_bet(request, market_uuid: str, payload: UserBetCreateSchema): + """Place a bet on a market option.""" + if not request.user.is_authenticated: + raise HttpError(401, "Authentication required") + + market = get_object_or_404(Market, uuid=market_uuid) + option = get_object_or_404(MarketOption, uuid=payload.option_uuid) + + if option.market_id != market.id: + raise HttpError(400, "Option does not belong to this market") + + if market.status != Market.Status.OPEN: + 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() + if existing_bet_on_market: + raise HttpError(400, "You can only bet on one option per market") + + # Check if user already has a bet on this option + existing_bet = UserBet.objects.filter(user=request.user, option=option).first() + if existing_bet and payload.amount < existing_bet.amount: + raise HttpError(400, "Cannot decrease bet amount. You can only increase it.") + + user_bet, created = UserBet.objects.update_or_create( + user=request.user, + option=option, + defaults={"amount": payload.amount}, + ) + + return user_bet diff --git a/polylan_submitter/market/migrations/0001_initial.py b/polylan_submitter/market/migrations/0001_initial.py index 3ffd3a7..c76dda0 100644 --- a/polylan_submitter/market/migrations/0001_initial.py +++ b/polylan_submitter/market/migrations/0001_initial.py @@ -7,7 +7,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/polylan_submitter/market/models.py b/polylan_submitter/market/models.py index 2a4a9a5..0030fb8 100644 --- a/polylan_submitter/market/models.py +++ b/polylan_submitter/market/models.py @@ -1,6 +1,5 @@ import uuid from django.db import models -from django.utils import timezone class BaseModel(models.Model): @@ -25,7 +24,9 @@ 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) + status = models.CharField( + max_length=10, choices=Status.choices, default=Status.OPEN + ) end_date = models.DateTimeField() created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT) winning_option = models.ForeignKey( @@ -69,8 +70,12 @@ class MarketOption(BaseModel): class UserBet(BaseModel): - user = models.ForeignKey("accounts.CustomUser", on_delete=models.CASCADE, related_name="bets") - option = models.ForeignKey(MarketOption, on_delete=models.CASCADE, related_name="user_bets") + user = models.ForeignKey( + "accounts.CustomUser", on_delete=models.CASCADE, related_name="bets" + ) + option = models.ForeignKey( + MarketOption, on_delete=models.CASCADE, related_name="user_bets" + ) amount = models.PositiveIntegerField() class Meta: diff --git a/polylan_submitter/market/schemas.py b/polylan_submitter/market/schemas.py new file mode 100644 index 0000000..2c387d2 --- /dev/null +++ b/polylan_submitter/market/schemas.py @@ -0,0 +1,59 @@ +from datetime import datetime +from typing import List, Optional, Any +from uuid import UUID +from ninja import Schema +from pydantic import field_serializer, model_validator + + +class MarketOptionSchema(Schema): + uuid: UUID + text: str + position: int + + @field_serializer('uuid') + def serialize_uuid(self, value: UUID) -> str: + return str(value) + + +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') + def serialize_uuid(self, value: UUID) -> str: + return str(value) + + +class ResolveMarketSchema(Schema): + winning_option_uuid: str + + +class UserBetCreateSchema(Schema): + option_uuid: str + amount: int + + +class UserBetSchema(Schema): + uuid: UUID + amount: int + created_at: datetime + option: MarketOptionSchema + market: Optional[MarketListSchema] = None + + @field_serializer('uuid') + def serialize_uuid(self, value: UUID) -> str: + return str(value) + + @model_validator(mode='before') + @classmethod + def resolve_market_from_option(cls, data: Any) -> Any: + 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 7ce503c..4929020 100644 --- a/polylan_submitter/market/tests.py +++ b/polylan_submitter/market/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/polylan_submitter/market/views.py b/polylan_submitter/market/views.py index 91ea44a..7db4082 100644 --- a/polylan_submitter/market/views.py +++ b/polylan_submitter/market/views.py @@ -1,3 +1,8 @@ +from django.contrib.auth.decorators import login_required from django.shortcuts import render +from django.http import HttpRequest -# Create your views here. + +@login_required +def market_home(request: HttpRequest): + return render(request, "market.html", {}) diff --git a/polylan_submitter/polylan_submitter/api.py b/polylan_submitter/polylan_submitter/api.py index 2a18131..9bbfa74 100644 --- a/polylan_submitter/polylan_submitter/api.py +++ b/polylan_submitter/polylan_submitter/api.py @@ -6,6 +6,7 @@ from submissions.schemas import UserInfoOut from animations.api import router as results_router from noita.api import router as noita_router from games.api import router as games_router +from market.api import router as market_router # Create the main API instance api = NinjaAPI( @@ -36,6 +37,7 @@ api.add_router("/submissions/", submissions_router, tags=["submissions"]) api.add_router("/results/", results_router, tags=["results"]) api.add_router("/noita/", noita_router, tags=["noita"]) api.add_router("/games/", games_router, tags=["games"]) +api.add_router("/market/", market_router) # Health check endpoint diff --git a/polylan_submitter/polylan_submitter/urls.py b/polylan_submitter/polylan_submitter/urls.py index 28626a6..ac169de 100644 --- a/polylan_submitter/polylan_submitter/urls.py +++ b/polylan_submitter/polylan_submitter/urls.py @@ -24,6 +24,7 @@ from django.conf import settings from django.conf.urls.static import static from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView from games.decorators import require_game_enabled +from market.views import market_home from .api import api NOITA_APP_ID = 881100 @@ -60,6 +61,7 @@ urlpatterns = [ path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"), path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"), path("api/", api.urls), + path("market", market_home, name="market.home"), path("opus-magnum", opus_magnum_home, name="opus-magnum.home"), path("noita", noita_home, name="noita.home"), path("", home, name="home"), diff --git a/polylan_submitter/src/Home.vue b/polylan_submitter/src/Home.vue index 488e1c9..5af54da 100644 --- a/polylan_submitter/src/Home.vue +++ b/polylan_submitter/src/Home.vue @@ -47,6 +47,26 @@ onMounted(async () => {
Place your bets and compete
+Place your bets on upcoming events
+{{ market.description }}
++ Bet on: {{ bet.option.text }} +
++ Correct! You bet on: {{ bet.option.text }} +
++ You bet on: {{ bet.option.text }} +
++ Winner: {{ bet.market.winning_option?.text }} +
+