Skip to content

Commit

Permalink
Migrate admin panel to django (#502)
Browse files Browse the repository at this point in the history
* Migrate admin panel to django

* fix static and media urls

* better ball preview

* add django-debug-toolbar

* just a lot of stuff dw

* fix search algorithm, remove some prefetchs

* model caching

* fix count when using filters

* update dependencies

* style: sticky card preview

* add discord oauth2

* check for empty config

* please django with sync

* use role snowflakes

* use sync_to_async

* custom login form with discord button

* models: set some default values

* move assets to media folder

* display ids

* configure settings module

* add pyinstrument profiler

* do not load debug toolbar in local

* always allow localhost

* remove static folder creation

* prepare docker compose environment

* search by PK

* display error on invalid search

* autocomplete filters

* add guildconfig model admin

* split admin.py file

* balls: handle large deletions

* regime: cut down on deletion confirm

* add admin action forms

* blacklist player action

* dual ID search in trades

* lower catch names and translations

* blacklist filters and actions on guilds

* display player's paginated inventory with catch times

* shorten line

* guild: display catch history

* fix preview display

* Add hyperlinks in admin commands

* 🎉 nuke fastapi-admin

* do not display preview in ball creation

* register SpecialAdmin

* display special preview

* poetry: add standard uvicorn marker

* docker: fix build

* migrations: handle new instances
  • Loading branch information
laggron42 authored Jan 21, 2025
1 parent f6b558e commit 4e15acf
Show file tree
Hide file tree
Showing 71 changed files with 3,880 additions and 956 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ pgbackups

# static
static
media

# cache
.pytest_cache

# settings
config.yml
docker-compose.override.yml
admin_panel/admin_panel/settings/production.py
19 changes: 13 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,19 +27,28 @@ repos:
- black
- --filter-files
- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.335
rev: v1.1.390
hooks:
- id: pyright
language_version: python3.13
additional_dependencies:
- discord
- cachetools
- rich
- Pillow
- prometheus_client
- tortoise-orm
- git+https://github.com/fastapi-admin/fastapi-admin.git
- aerich==0.6.3
- redis
- django
- dj_database_url
- django-stubs
- django-debug-toolbar
- django-nonrelated-inlines
- social-auth-app-django
- django-admin-autocomplete-filter
- django_admin_action_forms
- django_admin_inline_paginator
- repo: https://github.com/csachs/pyproject-flake8
rev: v7.0.0
hooks:
Expand Down
6 changes: 2 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,9 @@ RUN pip install poetry==2.0.1
WORKDIR /code
COPY poetry.lock pyproject.toml /code/

RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi

COPY . /code
RUN mkdir -p /code/static
RUN mkdir -p /code/static/uploads

RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi

# wait for postgres to be ready
CMD sleep 2
File renamed without changes.
8 changes: 8 additions & 0 deletions admin_panel/admin_panel/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.contrib import admin


class BallsdexAdminSite(admin.AdminSite):
site_header = "Ballsdex administration" # TODO: use configured bot name
site_title = "Ballsdex admin panel"
site_url = None
final_catch_all_view = False
5 changes: 5 additions & 0 deletions admin_panel/admin_panel/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.contrib.admin.apps import AdminConfig


class BallsdexAdminConfig(AdminConfig):
default_site = "admin_panel.admin.BallsdexAdminSite"
7 changes: 7 additions & 0 deletions admin_panel/admin_panel/asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import os

from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "admin_panel.settings")

application = get_asgi_application()
218 changes: 218 additions & 0 deletions admin_panel/admin_panel/pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
from enum import Enum
from typing import TYPE_CHECKING, Literal

import aiohttp
from asgiref.sync import async_to_sync, sync_to_async
from bd_models.models import (
Ball,
BallInstance,
BlacklistedGuild,
BlacklistedID,
BlacklistHistory,
Block,
Economy,
Friendship,
GuildConfig,
Player,
Regime,
Special,
Trade,
TradeObject,
)
from django.contrib import messages
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType

from ballsdex.settings import settings

from .webhook import notify_admins

if TYPE_CHECKING:
from django.contrib.auth.models import User
from django.db.models import Model
from django.http import HttpRequest
from social_core.backends.base import BaseAuth

type perm_dict = dict[
"type[Model]", list[Literal["view", "add", "change", "delete"]] | Literal["*"]
]

DISCORD_API = "https://discord.com/api/v10/"


class Status(Enum):
STAFF = 0 # has a role in the "admin-role-ids" of config.yml
ADMIN = 1 # has a role in the "root-role-ids" of config.yml
TEAM_MEMBER = 2 # is a member of the Discord team owning the application
CO_OWNER = 3 # has its ID in the "co-owners" section of config.yml
OWNER = 4 # owns the application


async def get_permissions(permissions: perm_dict) -> list[Permission]:
"""
Returns the list of permissions objects from a dictionnary mapping models to permission codes.
"""
result: list[Permission] = []
for model, perms in permissions.items():
content_type = await sync_to_async(ContentType.objects.get_for_model)(model)
if perms == "*":
perms = ["add", "change", "delete", "view"]
for perm in perms:
result.append(
await Permission.objects.aget(
content_type=content_type, codename=f"{perm}_{model._meta.model_name}"
)
)
return result


async def assign_status(request: "HttpRequest", response: dict, user: "User", status: Status):
"""
Assign the correct attributes and groups to the user based on the given status.
A message will be displayed to the user.
"""
notify = not user.is_staff

user.is_staff = True
if status == Status.STAFF:
user.is_superuser = False
group, created = await Group.objects.aget_or_create(name="Staff")
if created:
perms: perm_dict = {
BallInstance: ["view"],
BlacklistedGuild: "*",
BlacklistedID: "*",
BlacklistHistory: ["view"],
Block: "*",
Friendship: "*",
GuildConfig: ["view", "change"],
Player: ["view", "change"],
Trade: ["view"],
TradeObject: ["view"],
}
await group.permissions.aadd(*await get_permissions(perms))
await user.groups.aadd(group)
message = "You were assigned the Staff status because of your Discord roles."
elif status == Status.ADMIN:
user.is_superuser = False
group, created = await Group.objects.aget_or_create(name="Admin")
if created:
perms: perm_dict = {
Ball: "*",
Regime: "*",
Economy: "*",
Special: "*",
BallInstance: "*",
BlacklistedGuild: "*",
BlacklistedID: "*",
BlacklistHistory: ["view"],
Block: "*",
Friendship: "*",
GuildConfig: "*",
Player: "*",
Trade: ["view"],
TradeObject: ["view"],
}
await group.permissions.aadd(*await get_permissions(perms))
await user.groups.aadd(group)
message = "You were assigned the Admin status because of your Discord roles."
elif status == Status.TEAM_MEMBER:
user.is_superuser = True
message = (
"You were assigned the superuser status because you are a team member, "
"and the bot is configured to treat team members as owners."
)
elif status == Status.CO_OWNER:
user.is_superuser = True
message = "You were assigned the superuser status because you are a co-owner in config.yml"
elif status == Status.OWNER:
user.is_superuser = True
message = (
"You were assigned the superuser status because you are the owner of the application."
)
else:
raise ValueError(f"Unknown status: {status}")
await user.asave()

if notify:
messages.success(request, message)
await notify_admins(
f"{response['global_name']} (`{response['username']}`, {response['id']}) has been "
f"assigned the {status.name} status on the admin panel."
)


@async_to_sync
async def configure_status(
request: "HttpRequest", backend: "BaseAuth", user: "User", uid: str, response: dict, **kwargs
):
if backend.name != "discord":
return
if response["mfa_enabled"] is False:
messages.error(
request, "You cannot use an account without multi-factor authentication enabled."
)
return
discord_id = int(uid)

# check if user is a co-owner in config.yml (no API call required)
if settings.co_owners and discord_id in settings.co_owners:
await assign_status(request, response, user, Status.CO_OWNER)
return

headers = {"Authorization": f"Bot {settings.bot_token}"}
async with aiohttp.ClientSession(
base_url=DISCORD_API, headers=headers, raise_for_status=True
) as session:

# check if user owns the application, or is part of the team and team members are co owners
async with session.get("applications/@me") as resp:
info = await resp.json()
if info["owner"]["id"] == uid:
await assign_status(request, response, user, Status.OWNER)
return
if (
settings.team_owners
and info["team"]
and uid in (x["user"]["id"] for x in info["team"]["members"])
):
await assign_status(request, response, user, Status.TEAM_MEMBER)
return

# no admin guild configured, no roles, nothing to do
if not settings.admin_guild_ids or not (settings.admin_role_ids or settings.root_role_ids):
return

# check if the user owns roles configured as root/admin in config.yml
session.headers["Authorization"] = f"Bearer {response['access_token']}"
async with session.get("users/@me/guilds") as resp:
guilds = await resp.json()

for guild in guilds:
if int(guild["id"]) not in settings.admin_guild_ids:
continue
async with session.get(f"users/@me/guilds/{guild['id']}/member") as resp:
member = await resp.json()

# If we find the user with an "admin" role, we must keep iterating in case a "root"
# role is found later. If a "root" role is found, we can immediately stop and assign
is_staff = False
for role in member["roles"]:
if settings.root_role_ids and int(role) in settings.root_role_ids:
await assign_status(request, response, user, Status.ADMIN)
return
elif settings.admin_role_ids and int(role) in settings.admin_role_ids:
is_staff = True
if is_staff:
await assign_status(request, response, user, Status.STAFF)
return

# If we reached this point, the user has no administration role.
# A user object will have been created, but without is_staff, the admin panel will be blocked.
# It could also be an ex-staff member logging in, which must be handled manually
if user.is_staff or user.is_superuser:
await notify_admins(
f"{response['global_name']} (`{response['username']}`, {response['id']}) logged in to "
"the admin panel using Discord OAuth2, but no staff status has been found. "
f"{user.is_staff=} {user.is_superuser=}"
)
1 change: 1 addition & 0 deletions admin_panel/admin_panel/settings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .local import *
Loading

0 comments on commit 4e15acf

Please sign in to comment.