Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate admin panel to django #502

Draft
wants to merge 37 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
82ab87f
Migrate admin panel to django
laggron42 Jan 6, 2025
c3aa9cd
fix static and media urls
laggron42 Jan 7, 2025
957d77b
better ball preview
laggron42 Jan 7, 2025
50af019
add django-debug-toolbar
laggron42 Jan 10, 2025
d498af7
just a lot of stuff dw
laggron42 Jan 13, 2025
d576b35
fix search algorithm, remove some prefetchs
laggron42 Jan 13, 2025
d730b4e
model caching
laggron42 Jan 13, 2025
f34d647
fix count when using filters
laggron42 Jan 13, 2025
5e6f219
update dependencies
laggron42 Jan 14, 2025
d6bb677
style: sticky card preview
laggron42 Jan 14, 2025
664cc4e
add discord oauth2
laggron42 Jan 14, 2025
b7af3a6
check for empty config
laggron42 Jan 14, 2025
edef716
please django with sync
laggron42 Jan 14, 2025
f3d0d7e
use role snowflakes
laggron42 Jan 14, 2025
b96093c
use sync_to_async
laggron42 Jan 14, 2025
55cc537
custom login form with discord button
laggron42 Jan 15, 2025
3d2c5ac
models: set some default values
laggron42 Jan 15, 2025
5e44962
move assets to media folder
laggron42 Jan 15, 2025
ed45d5e
display ids
laggron42 Jan 15, 2025
33d8cfd
configure settings module
laggron42 Jan 15, 2025
1f4a5b0
add pyinstrument profiler
laggron42 Jan 17, 2025
064d637
do not load debug toolbar in local
laggron42 Jan 17, 2025
756666d
always allow localhost
laggron42 Jan 17, 2025
7316390
remove static folder creation
laggron42 Jan 17, 2025
8c3d02b
prepare docker compose environment
laggron42 Jan 17, 2025
345c632
search by PK
laggron42 Jan 17, 2025
fd2d808
display error on invalid search
laggron42 Jan 17, 2025
182d049
autocomplete filters
laggron42 Jan 17, 2025
15d1b0c
add guildconfig model admin
laggron42 Jan 17, 2025
77bc02b
split admin.py file
laggron42 Jan 17, 2025
1a373a7
balls: handle large deletions
laggron42 Jan 17, 2025
e6dbb3a
regime: cut down on deletion confirm
laggron42 Jan 17, 2025
6bffbe8
add admin action forms
laggron42 Jan 17, 2025
c1e0687
blacklist player action
laggron42 Jan 17, 2025
2ea7216
dual ID search in trades
laggron42 Jan 17, 2025
f6ab1b3
lower catch names and translations
laggron42 Jan 17, 2025
352ee5d
blacklist filters and actions on guilds
laggron42 Jan 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
17 changes: 12 additions & 5 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,9 +27,10 @@ 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
Expand All @@ -42,6 +41,14 @@ repos:
- 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
- repo: https://github.com/csachs/pyproject-flake8
rev: v7.0.0
hooks:
Expand Down
2 changes: 0 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ 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

# 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
Loading