Visualizes a DB schema based on Django models
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
    """Run administrative tasks."""
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.conf.settings')
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == '__main__':
    main()

manage.py
pikhovkin/django-schema-viewer
from django.apps import AppConfig


class SchemaViewerConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'schema_viewer'

schema_viewer/apps.py
pikhovkin/django-schema-viewer
from collections.abc import Iterator
from typing import Any, cast

from django import apps
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import connection, models


def get_app_models() -> Iterator[tuple[apps.AppConfig, type[models.Model]]]:
    for app in apps.apps.get_app_configs():
        for model in app.get_models():
            yield app, model


def get_app_name(model: type[models.Model]) -> str:
    app_label = model._meta.app_label
    try:
        return apps.apps.get_app_config(app_label).name
    except LookupError:
        return model.__module__


def get_model_id(model: type[models.Model]) -> str:
    return f"{model.__module__}.{model.__name__}"


def is_model_subclass(obj: type[models.Model]) -> bool:
    if obj is models.Model:
        return False
    return issubclass(obj, models.Model)


def get_schema(conf: dict | None = None) -> dict:
    if not conf:
        conf = getattr(settings, 'SCHEMA_VIEWER', {}) or {}

    app_names = conf.get('apps', []) or []
    excludes = conf.get('exclude', {}) or {}

    json_table_schema: dict[str, Any] = {
        'resources': [],
        'name': '',
    }

    resources = json_table_schema['resources']
    field_types: set[str] = set()
    for app, model in get_app_models():
        if app_names and app.name not in app_names:
            continue
        elif model._meta.proxy or model._meta.abstract:
            continue
        elif app.name in excludes and model.__name__.lower() in excludes[app.name]:
            continue

        resources.append(
            {
                'name': model._meta.db_table,
                'title': f'{app.name}.{model.__name__}',
                'description': model._meta.verbose_name,
                'schema': {
                    'fields': [],
                    'primaryKey': [],
                    'foreignKeys': [],
                },
            }
        )
        schema_fields: list = resources[-1]['schema']['fields']
        schema_primary_key: list = resources[-1]['schema']['primaryKey']
        schema_foreign_keys: list = resources[-1]['schema']['foreignKeys']

        for field in model._meta.get_fields():
            if isinstance(field, GenericForeignKey):
                continue
            elif not field.concrete:
                continue
            elif field.many_to_many:
                continue
            elif field.model is not model:
                continue

            field_name: str = field.attname
            db_type: str | None = field.db_type(connection)
            if db_type is None:
                continue
            field_types.add(db_type)
            schema_fields.append(
                {
                    'name': field_name,
                    'title': getattr(field, 'verbose_name', ''),
                    'description': getattr(field, 'verbose_name', ''),
                    'type': db_type,
                    'constraints': {
                        'required': not field.null,
                        'unique': field.unique,
                    },
                }
            )
            if field.is_relation:
                rel_model = cast(type[models.Model], field.related_model)
                if rel_model is None:
                    continue
                rel_model_app_name = get_app_name(rel_model)
                if app_names and rel_model_app_name not in app_names:
                    continue
                if rel_model_app_name in excludes and rel_model.__name__.lower() in excludes[rel_model_app_name]:
                    continue

                rel_field = rel_model._meta.pk
                if rel_field is None:
                    continue

                schema_foreign_keys.append(
                    {
                        'fields': field_name,
                        'reference': {
                            'resource': rel_model._meta.db_table,
                            'fields': [
                                rel_field.attname,
                            ],
                        },
                    }
                )
            elif field.primary_key:
                schema_primary_key.append(field_name)
    return json_table_schema

schema_viewer/schema.py
pikhovkin/django-schema-viewer
from django.test import TestCase

from schema_viewer.schema import get_schema


class SchemaTest(TestCase):
    def test_schema(self):
        tables = {
            'auth_permission',
            'django_admin_log',
            'auth_group',
            'auth_user',
            'django_content_type',
            'django_session',
        }
        schema = get_schema()
        self.assertTrue({t['name'] for t in schema['resources']} == tables)

schema_viewer/tests/test_schema.py
pikhovkin/django-schema-viewer
import json

from django.test import TestCase


class ViewTest(TestCase):
    def test_view(self):
        response = self.client.get('/schema-viewer/')
        self.assertContains(response, 'django-schema-viewer')

    def test_schema(self):
        tables = {
            'auth_permission',
            'django_admin_log',
            'auth_group',
            'auth_user',
            'django_content_type',
            'django_session',
        }

        response = self.client.get('/schema-viewer/schema/')
        schema = json.loads(response.content)
        self.assertTrue({t['name'] for t in schema['resources']}, tables)

schema_viewer/tests/test_view.py
pikhovkin/django-schema-viewer
from django.urls import path

from schema_viewer.views import IndexView, SchemaView

urlpatterns = [
    path('', IndexView.as_view()),
    path('schema/', SchemaView.as_view()),
]

schema_viewer/urls.py
pikhovkin/django-schema-viewer
from django.http import JsonResponse
from django.views.generic import TemplateView, View

from schema_viewer.schema import get_schema


class IndexView(TemplateView):
    template_name = 'schema_viewer/index.html'


class SchemaView(View):
    def get(self, request):  # noqa: ARG002
        return JsonResponse(get_schema())

schema_viewer/views.py
pikhovkin/django-schema-viewer
import os

from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.conf.settings')

application = get_asgi_application()

tests/conf/asgi.py
pikhovkin/django-schema-viewer
import os
from pathlib import Path


# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'

ALLOWED_HOSTS: list = ['*']


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'schema_viewer',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'tests.conf.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'tests.conf.wsgi.application'


# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': ':memory:',
    },
}


# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/

STATIC_ROOT = BASE_DIR / 'static'
STATIC_URL = '/static/'

# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

tests/conf/settings/__init__.py
pikhovkin/django-schema-viewer
from . import *


# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases

DB_DIR = BASE_DIR.parent.parent / 'data' / 'db'
DB_DIR.mkdir(parents=True, exist_ok=True)
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': DB_DIR / 'db.sqlite3',
    },
}

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/

STATIC_ROOT = BASE_DIR.parent.parent / 'data' / 'static'
STATIC_URL = '/static/'

tests/conf/settings/demo.py
pikhovkin/django-schema-viewer
from django.urls import path, include

urlpatterns = [
    path('schema-viewer/', include('schema_viewer.urls')),
]

tests/conf/urls.py
pikhovkin/django-schema-viewer
import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.conf.settings')

application = get_wsgi_application()

tests/conf/wsgi.py
pikhovkin/django-schema-viewer