From d498af7dbe3a84404f2761dca4de5364102dd094 Mon Sep 17 00:00:00 2001 From: "Auguste \"laggron42\" Charpentier" Date: Mon, 13 Jan 2025 12:10:42 +0100 Subject: [PATCH] just a lot of stuff dw --- .pre-commit-config.yaml | 7 +- admin_panel/bd_models/admin.py | 217 +++++++++++++++++- admin_panel/bd_models/models.py | 73 +++++- .../bd_models/static/bd_models/style.css | 2 +- admin_panel/bd_models/utils.py | 21 ++ admin_panel/preview/views.py | 2 +- .../admin/bd_models/ball/change_form.html | 98 ++++---- .../bd_models/ballinstance/change_form.html | 45 ++++ .../admin/bd_models/trade/change_form.html | 42 ++++ ballsdex/__main__.py | 5 +- poetry.lock | 16 +- pyproject.toml | 3 +- 12 files changed, 456 insertions(+), 75 deletions(-) create mode 100644 admin_panel/templates/admin/bd_models/ballinstance/change_form.html create mode 100644 admin_panel/templates/admin/bd_models/trade/change_form.html 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" %} - -
- {% block object-tools %} - {% if change and not is_popup %} - - {% endif %} + +
+ {% block object-tools %} + {% if change and not is_popup %} +
    + {% block object-tools-items %} + {% change_form_object_tools %} {% endblock %} -
    -
    {% csrf_token %}{% block form_top %}{% endblock %} -
    - {% if is_popup %}{% endif %} - {% if to_field %}{% endif %} - {% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %} - {% if errors %} -

    - {% blocktranslate count counter=errors|length %}Please correct the error below.{% plural %}Please - correct the errors below.{% endblocktranslate %} -

    - {{ adminform.form.non_field_errors }} - {% endif %} +
+ {% endif %} + {% endblock %} +
+ {% csrf_token %}{% block form_top %}{% endblock %} +
+ {% if is_popup %}{% endif %} + {% if to_field %}{% endif %} + {% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %} + {% if errors %} +

+ {% blocktranslate count counter=errors|length %}Please correct the error below.{% plural %}Please + correct the errors below.{% endblocktranslate %} +

+ {{ adminform.form.non_field_errors }} + {% endif %} - {% block field_sets %} - {% for fieldset in adminform %} - {% include "admin/includes/fieldset.html" with heading_level=2 prefix="fieldset" id_prefix=0 id_suffix=forloop.counter0 %} - {% endfor %} - {% endblock %} + {% block field_sets %} + {% for fieldset in adminform %} + {% include "admin/includes/fieldset.html" with heading_level=2 prefix="fieldset" id_prefix=0 id_suffix=forloop.counter0 %} + {% endfor %} + {% endblock %} - {% block after_field_sets %}{% endblock %} + {% block after_field_sets %}{% endblock %} - {% block inline_field_sets %} - {% for inline_admin_formset in inline_admin_formsets %} - {% include inline_admin_formset.opts.template %} - {% endfor %} - {% endblock %} + {% block inline_field_sets %} + {% for inline_admin_formset in inline_admin_formsets %} + {% include inline_admin_formset.opts.template %} + {% endfor %} + {% endblock %} - {% block after_related_objects %}{% endblock %} + {% block after_related_objects %}{% endblock %} - {% block submit_buttons_bottom %}{% submit_row %}{% endblock %} + {% block submit_buttons_bottom %}{% submit_row %}{% endblock %} - {% block admin_change_form_document_ready %} - - {% endblock %} + {% block admin_change_form_document_ready %} + + {% endblock %} - {# JavaScript for prepopulated fields #} - {% prepopulated_fields_js %} + {# JavaScript for prepopulated fields #} + {% prepopulated_fields_js %} -
- - {% include "generate_ball_preview.html" %} -
-
-{% else %} - {{ block.super }} -{% endif %} +
+ + {% include "generate_ball_preview.html" %} + + {% endblock %} \ No newline at end of file diff --git a/admin_panel/templates/admin/bd_models/ballinstance/change_form.html b/admin_panel/templates/admin/bd_models/ballinstance/change_form.html new file mode 100644 index 00000000..4a42adb9 --- /dev/null +++ b/admin_panel/templates/admin/bd_models/ballinstance/change_form.html @@ -0,0 +1,45 @@ +{% extends "admin/change_form.html" %} + +{% block after_related_objects %} + + +{% endblock %} \ No newline at end of file diff --git a/admin_panel/templates/admin/bd_models/trade/change_form.html b/admin_panel/templates/admin/bd_models/trade/change_form.html new file mode 100644 index 00000000..17823511 --- /dev/null +++ b/admin_panel/templates/admin/bd_models/trade/change_form.html @@ -0,0 +1,42 @@ +{% extends "admin/change_form.html" %} + +{% block after_related_objects %} + + +{% endblock %} \ No newline at end of file diff --git a/ballsdex/__main__.py b/ballsdex/__main__.py index 3e182616..e2c3bbc3 100755 --- a/ballsdex/__main__.py +++ b/ballsdex/__main__.py @@ -237,10 +237,13 @@ def filter(self, record): return True -async def init_tortoise(db_url: str): +async def init_tortoise(db_url: str, *, skip_migrations: bool = False): log.debug(f"Database URL: {db_url}") await Tortoise.init(config=TORTOISE_ORM) + if skip_migrations: + return + # migrations command = Command(TORTOISE_ORM, app="models") await command.init() diff --git a/poetry.lock b/poetry.lock index d810dd31..2703e755 100644 --- a/poetry.lock +++ b/poetry.lock @@ -580,6 +580,20 @@ files = [ django = ">=4.2.9" sqlparse = ">=0.2" +[[package]] +name = "django-nonrelated-inlines" +version = "0.2" +description = "Django admin inlines for unrelated models" +optional = false +python-versions = ">=3.6" +files = [ + {file = "django-nonrelated-inlines-0.2.tar.gz", hash = "sha256:e123010a3ad18b049781d6688b3ff45b2441e69fd63802bec94c37cd36c83611"}, + {file = "django_nonrelated_inlines-0.2-py3-none-any.whl", hash = "sha256:216a4513971c58568f450a2339064746624de718a946088534e86011b400ccc3"}, +] + +[package.dependencies] +Django = ">=2.0" + [[package]] name = "fastapi" version = "0.115.6" @@ -2319,4 +2333,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = ">=3.12, <3.14" -content-hash = "d98eca1067acf4d235c5e3c92afe70e5571e816e7e2b440cdae87ca79119616d" +content-hash = "e7eb44fe0a4aeb131015f74f445c1282824a8b4aac462e272f31b1ae7cb7f188" diff --git a/pyproject.toml b/pyproject.toml index 426127a7..eee594ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ tortoise-cli = "^0.1.2" # django admin panel django = "^5.1.4" +django-nonrelated-inlines = "^0.2" # misc rich = "^13.8.0" @@ -60,7 +61,7 @@ build-backend = "poetry.core.masonry.api" line-length = 99 [tool.flake8] -ignore = "W503,E203" +ignore = "W503,E203,E999" max-line-length = 99 [tool.isort]