-
- {% block object-tools-items %}
- {% change_form_object_tools %}
- {% endblock %}
-
-
+ {% block object-tools-items %}
+ {% change_form_object_tools %}
{% endblock %}
-
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 12a61d9c..0d6ce9de 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,20 +4,18 @@ files: .*\.py$
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.6.0
+ rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: settings\.py
- id: end-of-file-fixer
- id: check-added-large-files
files: ""
- - id: debug-statements
- files: .*\.py$
- id: mixed-line-ending
args:
- --fix=lf
- repo: https://github.com/psf/black-pre-commit-mirror
- rev: 24.8.0
+ rev: 24.10.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
@@ -46,6 +44,7 @@ repos:
- dj_database_url
- django-stubs
- django-debug-toolbar
+ - django-nonrelated-inlines
- repo: https://github.com/csachs/pyproject-flake8
rev: v7.0.0
hooks:
diff --git a/admin_panel/bd_models/admin.py b/admin_panel/bd_models/admin.py
index 13fca976..a9da0aac 100644
--- a/admin_panel/bd_models/admin.py
+++ b/admin_panel/bd_models/admin.py
@@ -1,20 +1,75 @@
+import itertools
from typing import TYPE_CHECKING, Any
from django.contrib import admin
+from django.contrib.messages import SUCCESS
+from django.db.models import Prefetch
from django.forms import Textarea
from django.utils.safestring import mark_safe
+from nonrelated_inlines.admin import NonrelatedTabularInline
-from .models import Ball, Economy, Regime, Special
-from .utils import transform_media
+from .models import (
+ Ball,
+ BallInstance,
+ BlacklistedID,
+ BlacklistHistory,
+ Economy,
+ Player,
+ Regime,
+ Special,
+ Trade,
+ TradeObject,
+)
+from .utils import ApproxCountPaginator, transform_media
if TYPE_CHECKING:
- from django.db.models import Field
- from django.http.request import HttpRequest
+ from django.db.models import Field, QuerySet
+ from django.http import HttpRequest, HttpResponse
+
+
+class BlacklistTabular(NonrelatedTabularInline):
+ model = BlacklistHistory
+ extra = 0
+ can_delete = False
+ verbose_name_plural = "Blacklist history"
+ fields = ("date", "reason", "moderator_id", "action_type")
+ readonly_fields = ("date", "moderator_id", "action_type")
+
+ def has_add_permission(self, request: "HttpRequest", obj: Any) -> bool: # type: ignore
+ return False
+
+ def get_form_queryset(self, obj: Player):
+ return BlacklistHistory.objects.filter(discord_id=obj.discord_id)
+
+
+@admin.register(Player)
+class PlayerAdmin(admin.ModelAdmin):
+ save_on_top = True
+ list_display = ("discord_id", "pk")
+ search_fields = ("discord_id",)
+ search_help_text = "Search for a Discord ID"
+ actions = ("blacklist_users",)
+ inlines = (BlacklistTabular,)
+
+ # TODO: permissions and form
+ @admin.action(description="Blacklist users")
+ async def blacklist_users(self, request: "HttpRequest", queryset: "QuerySet[Player]"):
+ result = await BlacklistedID.objects.abulk_create(
+ (
+ BlacklistedID(
+ discord_id=x.discord_id,
+ reason=f"Blacklisted via admin panel by {request.user}",
+ )
+ for x in queryset
+ )
+ )
+ self.message_user(request, f"Successfully created {len(result)} blacklists", SUCCESS)
@admin.register(Regime)
class RegimeAdmin(admin.ModelAdmin):
list_display = ("name", "background_image")
+ search_fields = ("name",)
@admin.display()
def background_image(self, obj: Regime):
@@ -26,6 +81,7 @@ def background_image(self, obj: Regime):
@admin.register(Economy)
class EconomyAdmin(admin.ModelAdmin):
list_display = ("name", "icon_image")
+ search_fields = ("name",)
@admin.display()
def icon_image(self, obj: Economy):
@@ -34,8 +90,9 @@ def icon_image(self, obj: Economy):
@admin.register(Ball)
class BallAdmin(admin.ModelAdmin):
- raw_id_fields = ["regime", "economy"]
- readonly_fields = ("collection_image", "spawn_image", "preview")
+ autocomplete_fields = ("regime", "economy")
+ readonly_fields = ("collection_image", "spawn_image")
+ save_on_top = True
fieldsets = [
(
None,
@@ -77,7 +134,14 @@ class BallAdmin(admin.ModelAdmin):
{
"description": "Advanced settings",
"classes": ["collapse"],
- "fields": ["enabled", "tradeable", "short_name", "catch_names", "translations"],
+ "fields": [
+ "enabled",
+ "tradeable",
+ "short_name",
+ "catch_names",
+ "translations",
+ "capacity_logic",
+ ],
},
),
]
@@ -116,6 +180,7 @@ def formfield_for_dbfield(
@admin.register(Special)
class SpecialAdmin(admin.ModelAdmin):
+ save_on_top = True
fieldsets = [
(
None,
@@ -164,3 +229,141 @@ def formfield_for_dbfield(
if db_field.name == "catch_phrase":
kwargs["widget"] = Textarea()
return super().formfield_for_dbfield(db_field, request, **kwargs) # type: ignore
+
+
+class SearchByHexIDMixin:
+ search_help_text = "Search by hexadecimal ID"
+ search_fields = ("id",) # field is ignored, but required for the text area to show up
+
+ def get_search_results(
+ self, request: "HttpRequest", queryset: "QuerySet[BallInstance]", search_term: str
+ ) -> "tuple[QuerySet[BallInstance], bool]":
+ if not search_term:
+ return super().get_search_results(request, queryset, search_term) # type: ignore
+ try:
+ return queryset.filter(id=int(search_term, 16)), False
+ except ValueError:
+ return queryset.none(), False
+
+
+@admin.register(BallInstance)
+class BallInstanceAdmin(SearchByHexIDMixin, admin.ModelAdmin):
+ autocomplete_fields = ("player", "trade_player", "ball", "special")
+ save_on_top = True
+ fieldsets = [
+ (None, {"fields": ("ball", "health_bonus", "attack_bonus", "special")}),
+ ("Ownership", {"fields": ("player", "favorite", "catch_date", "trade_player")}),
+ (
+ "Advanced",
+ {
+ "classes": ("collapse",),
+ "fields": ("tradeable", "server_id", "spawned_time", "locked", "extra_data"),
+ },
+ ),
+ ]
+
+ list_display = ("description", "ball__country", "player", "health_bonus", "attack_bonus")
+ list_select_related = ("ball", "special")
+ # TODO: filter by special or ball (needs extension)
+ list_filter = ("tradeable", "favorite")
+ show_full_result_count = False
+ paginator = ApproxCountPaginator
+
+ def change_view(
+ self,
+ request: "HttpRequest",
+ object_id: str,
+ form_url: str = "",
+ extra_context: dict[str, Any] | None = None,
+ ) -> "HttpResponse":
+ obj = BallInstance.objects.prefetch_related("player").get(id=object_id)
+
+ def _get_trades():
+ trade_ids = TradeObject.objects.filter(ballinstance=obj).values_list(
+ "trade_id", flat=True
+ )
+ for trade in Trade.objects.filter(id__in=trade_ids).prefetch_related(
+ "player1",
+ "player2",
+ Prefetch(
+ "tradeobject_set",
+ queryset=TradeObject.objects.prefetch_related(
+ "ballinstance", "ballinstance__ball", "player"
+ ),
+ ),
+ ):
+ player1_proposal = [
+ x for x in trade.tradeobject_set.all() if x.player_id == trade.player1_id
+ ]
+ player2_proposal = [
+ x for x in trade.tradeobject_set.all() if x.player_id == trade.player2_id
+ ]
+ yield {
+ "model": trade,
+ "proposals": (player1_proposal, player2_proposal),
+ }
+
+ extra_context = extra_context or {}
+ extra_context["trades"] = list(_get_trades())
+ return super().change_view(request, object_id, form_url, extra_context)
+
+
+@admin.register(Trade)
+class TradeAdmin(SearchByHexIDMixin, admin.ModelAdmin):
+ fields = ("player1", "player2", "date")
+ list_display = ("__str__", "player1", "player1_items", "player2", "player2_items")
+ readonly_fields = ("date",)
+ autocomplete_fields = ("player1", "player2")
+
+ def get_queryset(self, request: "HttpRequest") -> "QuerySet[Trade]":
+ qs: "QuerySet[Trade]" = super().get_queryset(request)
+ return qs.prefetch_related(
+ "player1",
+ "player2",
+ Prefetch(
+ "tradeobject_set",
+ queryset=TradeObject.objects.prefetch_related(
+ "ballinstance", "ballinstance__ball"
+ ),
+ ),
+ )
+
+ # It is important to use .all() and manually filter in python rather than using .filter.count
+ # since the property is already prefetched and cached. Using .filter forces a new query
+ def player1_items(self, obj: Trade):
+ return len([None for x in obj.tradeobject_set.all() if x.player_id == obj.player1_id])
+
+ def player2_items(self, obj: Trade):
+ return len([None for x in obj.tradeobject_set.all() if x.player_id == obj.player2_id])
+
+ # The Trade model object is needed in `change_view`, but the admin does not provide it yet
+ # at this time. To avoid making the same query twice and slowing down the page loading,
+ # the model is cached here
+ def get_object(self, request: "HttpRequest", object_id: str, from_field: None = None) -> Trade:
+ if not hasattr(request, "object"):
+ request.object = self.get_queryset(request).get(id=object_id) # type: ignore
+ return request.object # type: ignore
+
+ # This adds extra context to the template, needed for the display of TradeObject models
+ def change_view(
+ self,
+ request: "HttpRequest",
+ object_id: str,
+ form_url: str = "",
+ extra_context: dict[str, Any] | None = None,
+ ) -> "HttpResponse":
+ obj = self.get_object(request, object_id)
+
+ # force queryset evaluation now to avoid double evaluation (with the len below)
+ objects = list(obj.tradeobject_set.all())
+ player1_objects = [x for x in objects if x.player_id == obj.player1_id]
+ player2_objects = [x for x in objects if x.player_id == obj.player2_id]
+ objects = itertools.zip_longest(player1_objects, player2_objects)
+
+ extra_context = extra_context or {}
+ extra_context["player1"] = obj.player1
+ extra_context["player2"] = obj.player2
+ extra_context["trade_objects"] = objects
+ extra_context["player1_len"] = len(player1_objects)
+ extra_context["player2_len"] = len(player2_objects)
+ return super().change_view(request, object_id, form_url, extra_context)
diff --git a/admin_panel/bd_models/models.py b/admin_panel/bd_models/models.py
index d022af1b..5884b276 100644
--- a/admin_panel/bd_models/models.py
+++ b/admin_panel/bd_models/models.py
@@ -1,5 +1,13 @@
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import cast
+
+from django.contrib import admin
+from django.core.cache import cache
from django.db import models
from django.utils.safestring import SafeText, mark_safe
+from django.utils.timezone import now
from .utils import transform_media
@@ -65,8 +73,22 @@ class Player(models.Model):
choices=FriendPolicy.choices, help_text="Open or close your friend requests"
)
+ def is_blacklisted(self) -> bool:
+ blacklist = cast(
+ list[int],
+ cache.get_or_set(
+ "blacklist",
+ BlacklistedID.objects.all().values_list("discord_id", flat=True),
+ timeout=300,
+ ),
+ )
+ return self.discord_id in blacklist
+
def __str__(self) -> str:
- return str(self.discord_id)
+ return (
+ f"{'\N{NO MOBILE PHONES} ' if self.is_blacklisted() else ''}#"
+ f"{self.pk} ({self.discord_id})"
+ )
class Meta:
managed = False
@@ -153,7 +175,7 @@ class Ball(models.Model):
capacity_description = models.CharField(
max_length=256, help_text="Description of the countryball's capacity"
)
- capacity_logic = models.JSONField(help_text="Effect of this capacity", editable=False)
+ capacity_logic = models.JSONField(help_text="Effect of this capacity", blank=True)
enabled = models.BooleanField(help_text="Enables spawning and show in completion")
short_name = models.CharField(
max_length=12,
@@ -175,25 +197,25 @@ class Ball(models.Model):
null=True,
help_text="Economical regime of this country",
)
+ economy_id: int | None
regime = models.ForeignKey(
Regime, on_delete=models.CASCADE, help_text="Political regime of this country"
)
+ regime_id: int
created_at = models.DateTimeField(blank=True, null=True, auto_now_add=True, editable=False)
translations = models.TextField(blank=True, null=True)
def __str__(self) -> str:
return self.country
+ @admin.display(description="Current collection card")
def collection_image(self) -> SafeText:
return image_display(str(self.collection_card))
- collection_image.short_description = "Current collection card"
-
+ @admin.display(description="Current spawn asset")
def spawn_image(self) -> SafeText:
return image_display(str(self.wild_card))
- spawn_image.short_description = "Current spawn asset"
-
class Meta:
managed = False
db_table = "ball"
@@ -204,7 +226,9 @@ class BallInstance(models.Model):
health_bonus = models.IntegerField()
attack_bonus = models.IntegerField()
ball = models.ForeignKey(Ball, on_delete=models.CASCADE)
+ ball_id: int
player = models.ForeignKey(Player, on_delete=models.CASCADE)
+ player_id: int
trade_player = models.ForeignKey(
Player,
on_delete=models.SET_NULL,
@@ -212,18 +236,38 @@ class BallInstance(models.Model):
blank=True,
null=True,
)
+ trade_player_id: int | None
favorite = models.BooleanField()
special = models.ForeignKey(Special, on_delete=models.SET_NULL, blank=True, null=True)
+ special_id: int | None
server_id = models.BigIntegerField(
blank=True, null=True, help_text="Discord server ID where this ball was caught"
)
tradeable = models.BooleanField()
- extra_data = models.JSONField()
+ extra_data = models.JSONField(blank=True)
locked = models.DateTimeField(
blank=True, null=True, help_text="If the instance was locked for a trade and when"
)
spawned_time = models.DateTimeField(blank=True, null=True)
+ def __str__(self) -> str:
+ text = ""
+ if self.locked and self.locked > now() - timedelta(minutes=30):
+ text += "🔒"
+ if self.favorite:
+ text += "❤️"
+ if text:
+ text += " "
+ if self.special:
+ text += self.special.emoji or ""
+ return f"{text}#{self.pk:0X} {self.ball.country}"
+
+ @admin.display(description="Countryball")
+ def description(self) -> SafeText:
+ text = str(self)
+ emoji = f''
+ return mark_safe(f"{emoji} {text} ATK:{self.attack_bonus:+d}% HP:{self.health_bonus:+d}%")
+
class Meta:
managed = False
db_table = "ballinstance"
@@ -269,17 +313,26 @@ class Meta:
class Trade(models.Model):
date = models.DateTimeField(auto_now_add=True, editable=False)
player1 = models.ForeignKey(Player, on_delete=models.CASCADE)
+ player1_id: int
player2 = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="trade_player2_set")
+ player2_id: int
+ tradeobject_set: models.QuerySet[TradeObject]
+
+ def __str__(self) -> str:
+ return f"Trade #{self.pk:0X}"
class Meta:
managed = False
db_table = "trade"
-class Tradeobject(models.Model):
+class TradeObject(models.Model):
ballinstance = models.ForeignKey(BallInstance, on_delete=models.CASCADE)
+ ballinstance_id: int
player = models.ForeignKey(Player, on_delete=models.CASCADE)
+ player_id: int
trade = models.ForeignKey(Trade, on_delete=models.CASCADE)
+ trade_id: int
class Meta:
managed = False
@@ -289,9 +342,11 @@ class Meta:
class Friendship(models.Model):
since = models.DateTimeField(auto_now_add=True, editable=False)
player1 = models.ForeignKey(Player, on_delete=models.CASCADE)
+ player1_id: int
player2 = models.ForeignKey(
Player, on_delete=models.CASCADE, related_name="friendship_player2_set"
)
+ player2_id: int
class Meta:
managed = False
@@ -301,7 +356,9 @@ class Meta:
class Block(models.Model):
date = models.DateTimeField(auto_now_add=True, editable=False)
player1 = models.ForeignKey(Player, on_delete=models.CASCADE)
+ player1_id: int
player2 = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="block_player2_set")
+ player2_id: int
class Meta:
managed = False
diff --git a/admin_panel/bd_models/static/bd_models/style.css b/admin_panel/bd_models/static/bd_models/style.css
index 16a49472..582d8e7f 100644
--- a/admin_panel/bd_models/static/bd_models/style.css
+++ b/admin_panel/bd_models/static/bd_models/style.css
@@ -1,5 +1,5 @@
#preview-sidebar {
- flex: 0 0 30%;
+ flex: 1 2 auto;
order: 1;
background: var(--darkened-bg);
border-left: none;
diff --git a/admin_panel/bd_models/utils.py b/admin_panel/bd_models/utils.py
index 410c59af..d7153f4e 100644
--- a/admin_panel/bd_models/utils.py
+++ b/admin_panel/bd_models/utils.py
@@ -1,3 +1,24 @@
+from django.core.paginator import Paginator
+from django.db import connection
+from django.utils.functional import cached_property
+
+
+class ApproxCountPaginator(Paginator):
+ @cached_property
+ def count(self):
+ """Return the total number of objects, across all pages."""
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "SELECT reltuples AS estimate FROM pg_class where relname = "
+ f"'{self.object_list.model._meta.db_table}';" # type: ignore
+ )
+ result = int(cursor.fetchone()[0])
+ if result < 100000:
+ return super().count
+ else:
+ return result
+
+
def transform_media(path: str) -> str:
return path.replace("/static/uploads/", "").replace(
"/ballsdex/core/image_generator/src/", "default/"
diff --git a/admin_panel/preview/views.py b/admin_panel/preview/views.py
index c70ce8f4..ed13d44a 100644
--- a/admin_panel/preview/views.py
+++ b/admin_panel/preview/views.py
@@ -21,7 +21,7 @@ async def render_image(request: HttpRequest, ball_pk: int) -> HttpResponse:
from ballsdex.core.image_generator.image_gen import draw_card
if not Tortoise._inited:
- await init_tortoise(os.environ["BALLSDEXBOT_DB_URL"])
+ await init_tortoise(os.environ["BALLSDEXBOT_DB_URL"], skip_migrations=True)
balls.clear()
for ball in await Ball.all():
balls[ball.pk] = ball
diff --git a/admin_panel/templates/admin/bd_models/ball/change_form.html b/admin_panel/templates/admin/bd_models/ball/change_form.html
index 32f88c11..e93dff06 100644
--- a/admin_panel/templates/admin/bd_models/ball/change_form.html
+++ b/admin_panel/templates/admin/bd_models/ball/change_form.html
@@ -10,64 +10,60 @@
{% load i18n admin_urls static admin_modify %}
{% block content %}
-{% if request.resolver_match.url_name == "bd_models_ball_change" %}
-
-