Как создать сгруппированный QuerySet для использования в ModelAdmin.get_queryset - PullRequest
0 голосов
/ 03 октября 2019

Я хочу иметь возможность отображать список запрещенных IP-адресов, сгруппированных по ip, отсортированных по убыванию по количеству и последнему времени бана, в виде списка изменений администратора.

fail2ban генерирует sqlite-db и debian-8 / fail2ban-debian-0.9.6 генерирует эту таблицу:

"CREATE TABLE bans(" \
    "jail TEXT NOT NULL, " \
    "ip TEXT, " \
    "timeofban INTEGER NOT NULL, " \
    "data JSON, " \
    "FOREIGN KEY(jail) REFERENCES jails(name) " \
    ");" \
    "CREATE INDEX bans_jail_timeofban_ip ON bans(jail, timeofban);" \
    "CREATE INDEX bans_jail_ip ON bans(jail, ip);" \
    "CREATE INDEX bans_ip ON bans(ip);"

SQL, который я бы хотел создать django, должен возвращать те же результаты, что и этот SQL:

SELECT
    ip,
    strftime('%Y-%m-%d %H:%M:%S', timeofban, 'unixepoch', 'localtime') as latest,
    COUNT(ip) as ipcount
FROM
    bans
GROUP BY
    ip
ORDER BY
    ipcount DESC,
    timeofban DESC

Итак, я начал настраивать дополнительную базу данных в настройках:

DATABASES = {
    ...
    'fail2ban': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': '/var/lib/fail2ban/fail2ban.sqlite3',
    }
}

DATABASE_ROUTERS = [
    'fail2ban.dbrouter.Fail2BanRouter'
]

Создал модель:

    from django.db import models

    class Ban(models.Model):
       jail = models.CharField('jail', max_length=250)
       ip = models.CharField('ip', max_length=100)
       timeofban = models.IntegerField('timeofban', primary_key=True)
       data = models.TextField('data', blank=True, null=True)

   class Meta:
       db_table = 'bans'
       ordering = ["-timeofban"]
       managed = False

   def save(self, **kwargs):
       raise NotImplementedError()

Установите администратора:

import datetime
from django.contrib import admin
from django.db.models import Count, Max, OuterRef, Subquery

from .models import Ban

@admin.register(Ban)
class BanAdmin(admin.ModelAdmin):
    list_display = ['ip', 'jail', 'get_timeofban']

    def has_add_permission(self, request):
        return False

    def has_delete_permission(self, request, obj=None):
        return False

    def has_change_permission(self, request, obj=None):
        return False

    def has_view_permission(self, request, obj=None):
        return True

    def get_timeofban(self, instance):
        return datetime.datetime.fromtimestamp(instance.timeofban).strftime('%Y-%m-%d %H:%M:%S')
    get_timeofban.short_description = 'timeofban'
    get_timeofban.admin_order_field = 'timeofban'


    def get_queryset(self, request):

        qs = super().get_queryset(request)

        duplicates = qs.values('ip') \
                       .annotate(ipcount=Count('ip'), latest=Max('timeofban')) \
                       .order_by('-ipcount') \
                       .filter(ipcount__gt=10) \
                       .values_list('latest', flat=True)

        qs = qs.filter(timeofban__in=duplicates)

        subquery = Ban.objects.values('ip') \
                     .annotate(ipcount=Count('ip')) \
                     .filter(ip=OuterRef('ip')) \
                     .order_by('-ipcount') \
                     .values('ipcount')

        return qs.annotate(
                   ipcount=Subquery(subquery)
               ).order_by('-ipcount')

Это единственный способ создать QuerySet, который мне нужен для администратора. БД имеет примерно 80000 строк, поэтому без предварительной фильтрации невозможно достичь этого с помощью подзапроса. И я не могу поверить, что в django этот sql - единственный способ получить желаемые результаты:

SELECT 
"bans"."jail", "bans"."ip", "bans"."timeofban", "bans"."data",
(
    SELECT COUNT(U0."ip") AS "ipcount" FROM "bans" U0 
    WHERE U0."ip" = ("bans"."ip") 
    GROUP BY U0."ip" ORDER BY "ipcount" DESC
) AS "ipcount" 
FROM "bans" 
WHERE "bans"."timeofban" IN (SELECT MAX(U0."timeofban") AS "latest" 
FROM "bans" U0 
GROUP BY U0."ip" HAVING COUNT(U0."ip") > 10) 
ORDER BY "ipcount" DESC
LIMIT 21

Я знаю, что могу получить ValuesQuerySet, создав запрос, который выглядит почти так, как мне нужно:

from django import db
from .models import Ban
Ban.objects.order_by('-ipcount').values('ip').annotate(ipcount=Count('ip'))
db.connections['fail2ban'].queries[-1]['sql']
# 'SELECT "bans"."ip", COUNT("bans"."ip") AS "ipcount" FROM "bans" GROUP BY "bans"."ip" ORDER BY "ipcount" DESC  LIMIT 21'

, но даже здесь я озадачен тем, как я мог бы извлечь из этого дополнительное поле 'timeofban'.

Поэтому мой вопрос, есть ли что-то, что я упустил в достижении SQLя хотел бы, чтобы django создавал как QuerySet (не ValueQuerySet) и лучшее решение, чем Subquery-Overkill, который я придумал.

1 Ответ

0 голосов
/ 06 октября 2019

В итоге я добавил поле id первичного ключа-автоинкремента в таблицу fail2ban-sqlite, которое не должно мешать fail2ban, и использовал прокси-модель bans с пользовательским менеджером:

class Ban(models.Model):
    jail = models.CharField('jail', max_length=255)
    ip = models.GenericIPAddressField('ip', protocol='IPv4')
    timeofban = models.IntegerField('timeofban')
    data = models.TextField('data')
    id = models.IntegerField('id', primary_key=True)

    objects = BanManager()

    class Meta:
        db_table = 'bans'
        verbose_name = "Ban"
        verbose_name_plural = "Bans"
        managed = False

    def save(self, **kwargs):
        raise NotImplementedError()


class BanAggregatedManager(models.Manager):
    def get_queryset(self):
        qs = super().get_queryset()
        qs = qs.defer('data')

        latest = qs.values('ip') \
                   .annotate(Count('ip')) \
                   .order_by() \
                   .annotate(uid=Max('id')) \
                   .values('uid')

        ipcount = qs.filter(ip=OuterRef('ip')) \
                    .values('ip') \
                    .annotate(c=Count('ip')) \
                    .order_by() \
                    .values('c')

        annotated = qs.filter(id__in=Subquery(latest)) \
                      .annotate(ipcount=Subquery(ipcount)) \
                      .order_by('-ipcount')

        return annotated


class BanAggregated(Ban):
    objects = BanAggregatedManager()

    class Meta:
        proxy = True
        verbose_name = 'Ban Aggregated'
        verbose_name_plural = 'Bans Aggregated'
        managed = False

    def save(self, **kwargs):
        raise NotImplementedError()

Админ теперь выглядит так:

@admin.register(BanAggregated)
class BanAggregatedAdmin(BanAdmin):
    list_display = ['ip', 'get_timeofban', 'ipcount']
    list_per_page = 20

    def ipcount(self, instance):
        return instance.ipcount
    ipcount.short_description = 'Count'
    ipcount.admin_order_field = 'ipcount'

Пока у меня хорошо работает.

...