diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f40a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +*.sw* +db.sqlite3 +.pytest_cache +.idea/ +*.mo +.env +venv/ +.venv/ + + +# collectstatic +src/static/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7388e5d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +default_language_version: + python: python3 +exclude: ^.*\b(migrations)\b.*$ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-ast + - id: check-merge-conflict + - id: check-case-conflict + - id: detect-private-key + - id: check-added-large-files + - id: check-json + - id: check-symlinks + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.5.2' + hooks: + - id: ruff + args: + - --fix + - id: ruff-format + - repo: https://github.com/asottile/pyupgrade + rev: v3.16.0 + hooks: + - id: pyupgrade + args: + - --py311-plus + exclude: migrations/ + - repo: https://github.com/adamchainz/django-upgrade + rev: 1.19.0 + hooks: + - id: django-upgrade + args: + - --target-version=5.0 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..77ac257 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2023-present Víðir Valberg Guðmundsson + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9956d4 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# django-admin-tui + +[![PyPI - Version](https://img.shields.io/pypi/v/django-admin-tui.svg)](https://pypi.org/project/django-admin-tui) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-admin-tui.svg)](https://pypi.org/project/django-admin-tui) + +----- + +**Table of Contents** + +- [About](#about) +- [Installation](#installation) +- [License](#license) + +## About + +`django-admin-tui` is a project aiming to render the Django admin in a text-based user interface (TUI) +using [Textual](https://textual.textualize.io/), bringing one of Djangos killer features to a terminal near you. + +## Installation + +```console +pip install django-admin-tui +``` + +## License + +`django-admin-tui` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. diff --git a/django_admin_tui/__about__.py b/django_admin_tui/__about__.py new file mode 100644 index 0000000..3765213 --- /dev/null +++ b/django_admin_tui/__about__.py @@ -0,0 +1,6 @@ +"""About module for django_admin_tui.""" + +# SPDX-FileCopyrightText: 2023-present Víðir Valberg Guðmundsson +# +# SPDX-License-Identifier: MIT +__version__ = "0.0.1" diff --git a/django_admin_tui/__init__.py b/django_admin_tui/__init__.py new file mode 100644 index 0000000..c1ca5da --- /dev/null +++ b/django_admin_tui/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2023-present Víðir Valberg Guðmundsson +# +# SPDX-License-Identifier: MIT diff --git a/django_admin_tui/management/__init__.py b/django_admin_tui/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_admin_tui/management/commands/__init__.py b/django_admin_tui/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_admin_tui/management/commands/admin_tui.py b/django_admin_tui/management/commands/admin_tui.py new file mode 100644 index 0000000..afcaf9d --- /dev/null +++ b/django_admin_tui/management/commands/admin_tui.py @@ -0,0 +1,18 @@ +"""Management command to run the Django Admin TUI.""" + +from typing import Any + +from django.core.management import BaseCommand + +from django_admin_tui.tui import DjangoAdminTUI + + +class Command(BaseCommand): + """Management command to run the Django Admin TUI.""" + + help = """Run a TUI to browse django models.""" + + def handle(self, *args: Any, **options: Any) -> None: # noqa: ARG002, ANN401 + """Handle the management command.""" + app = DjangoAdminTUI() + app.run() diff --git a/django_admin_tui/tui.py b/django_admin_tui/tui.py new file mode 100644 index 0000000..2e5f066 --- /dev/null +++ b/django_admin_tui/tui.py @@ -0,0 +1,189 @@ +"""The core of the Django Admin TUI.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +from asgiref.sync import sync_to_async +from django.apps import apps +from django.contrib import admin +from django.contrib.admin.utils import display_for_field +from django.contrib.admin.utils import display_for_value +from django.contrib.admin.utils import label_for_field +from django.contrib.admin.utils import lookup_field +from django.db import models +from textual import log +from textual import on +from textual.app import App +from textual.containers import Container +from textual.widget import Widget +from textual.widgets import Button +from textual.widgets import DataTable +from textual.widgets import Footer +from textual.widgets import Header +from textual.widgets import Label +from textual.widgets import Rule +from textual.widgets import Tree + +if TYPE_CHECKING: + from django.contrib.admin import ModelAdmin + from django.db.models import Model + from textual.app import ComposeResult + + +class DjangoAdminTUI(App): + """The main TUI application for the Django Admin TUI.""" + + def compose(self) -> ComposeResult: + """Compose the main app.""" + app_dict = defaultdict(list) + + for model, model_admin in admin.site._registry.items(): + app_label = model._meta.app_label + + # get AppConfig instance for the app + app_config = apps.get_app_config(app_label) + + if app_config.verbose_name: + app_label = str(app_config.verbose_name).upper() + + model_dict = { + "model": model, + "model_admin": model_admin, + } + app_dict[app_label].append(model_dict) + + yield RegistryTreeWidget(app_dict=app_dict) + + yield Header(show_clock=True) + + yield Container(id="main") + + yield Footer() + + @on(Tree.NodeSelected) + @on(Tree.NodeHighlighted) + async def on_node_selected(self, event: Tree.NodeSelected | Tree.NodeHighlighted) -> None: + """Handle .""" + if not event.node.data: + return + model = event.node.data["model"] + model_admin = event.node.data["model_admin"] + container = self.query_one("#main") + container.styles.border = "solid", "white" + container.styles.border_title = model._meta.verbose_name + await container.remove_children() + await container.mount(ModelListView(model=model, model_admin=model_admin)) + + +class RegistryTreeWidget(Widget): + """A widget that displays the registry tree of Django ModelAdmins.""" + + DEFAULT_CSS = """ + RegistryTreeWidget { + dock: left; + layout: vertical; + height: auto; + offset: 0 1; + } + """ + + def __init__(self, app_dict: dict[str, list[dict[str, Model | ModelAdmin]]]) -> None: + self.app_dict = app_dict + super().__init__() + + def compose(self) -> ComposeResult: + """Compose the widget.""" + tree = Tree("Django Admin") + tree.show_root = False + + longest_app_label = max(len(app_label) for app_label in self.app_dict) + + self.styles.width = longest_app_label + 5 + + # Each app has a label and a list of registered models + for app_label, model_dicts in self.app_dict.items(): + app_node = tree.root.add(app_label) + for model_dict in model_dicts: + app_node.add_leaf(model_dict["model"]._meta.verbose_name_plural.capitalize(), data=model_dict) + + tree.root.expand_all() + yield tree + + +class ModelListView(Widget): + """A widget that displays a list of objects for a Django model. + + This is the equivalent of the Django admins changelist view. + """ + + DEFAULT_CSS = """ + ListView { + layout: vertical; + height: auto; + border: solid white; + } + """ + + model_admin: ModelAdmin + model: Model + + def __init__(self, *, model_admin: ModelAdmin, model: Model) -> None: + self.model_admin = model_admin + self.model = model + super().__init__() + + def compose(self) -> ComposeResult: + """Compose the widget.""" + self.border_title = self.model.__name__ + + yield Label(f"Select {self.model._meta.verbose_name} to change") + + # Add button to add a new object + yield Button("Add", id="add_button") + + yield Rule() + + # Create a data table + yield DataTable(cursor_type="row") + + async def on_mount(self) -> None: + """Handle the mount event.""" + table = self.query_one(DataTable) + + columns = [] + + for field_name in self.model_admin.list_display: + text, attr = label_for_field(field_name, self.model, model_admin=self.model_admin, return_attr=True) + columns.append(str(text)) + + log(f"Columns: {columns}") + + table.add_columns(*columns) + + async for obj in self.model.objects.all(): + empty_value_display = self.model_admin.get_empty_value_display() + row = [] + + for field_name in self.model_admin.list_display: + f, attr, value = await sync_to_async( + lookup_field, + )(field_name, obj, self.model_admin) + empty_value_display = getattr(attr, "empty_value_display", empty_value_display) + log(f"Field: {f}, Attr: {attr}, Value: {value}") + if f is None or f.auto_created: + boolean = getattr(attr, "boolean", False) + # Set boolean for attr that is a property, if defined. + if isinstance(attr, property) and hasattr(attr, "fget"): + boolean = getattr(attr.fget, "boolean", False) + result_repr = display_for_value(value, empty_value_display, boolean) + elif isinstance(f.remote_field, models.ManyToOneRel): + field_val = getattr(obj, f.name) + result_repr = empty_value_display if field_val is None else field_val + else: + result_repr = display_for_field(value, f, empty_value_display) + + row.append(result_repr) + log(f"Row: {row}") + table.add_row(*row) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eb3d712 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,78 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "django-admin-tui" +dynamic = ["version"] +description = 'Django Admin in the terminal.' +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +keywords = [] +authors = [ + { name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "Django>=4.2", + "textual>=0.79" +] + +[project.urls] +Documentation = "https://github.com/valberg/django-admin-tui#readme" +Issues = "https://github.com/valberg/django-admin-tui/issues" +Source = "https://github.com/valberg/django-admin-tui" + +[tool.hatch.version] +path = "django_admin_tui/__about__.py" + +[tool.ruff] +target-version = "py39" +extend-exclude = [ + ".git", + "__pycache__", + "manage.py", + "asgi.py", + "wsgi.py", +] +line-length = 120 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "G004", # Logging statement uses f-string + "ANN101", # Missing type annotation for `self` in method + "ANN102", # Missing type annotation for `cls` in classmethod + "EM101", # Exception must not use a string literal, assign to variable first + "EM102", # Exception must not use a f-string literal, assign to variable first + "COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) + "ISC001", # single-line-implicit-string-concatenation (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) + "D105", # Missing docstring in magic method + "D106", # Missing docstring in public nested class + "FIX", # TODO, FIXME, XXX + "TD", # TODO, FIXME, XXX + "D104", # Missing docstring in public package + "D107", # Missing docstring in __init__ + "ANN002", # Missing type annotation for `*args` + "ANN003", # Missing type annotation for `**kwargs` + "SLF001", # Access to a protected member +] + +[tool.ruff.lint.isort] +force-single-line = true + +[tool.ruff.lint.per-file-ignores] +"tests.py" = [ + "S101", # Use of assert + "SLF001", # Private member access + "D100", # Docstrings + "D103", # Docstrings +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c1ca5da --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2023-present Víðir Valberg Guðmundsson +# +# SPDX-License-Identifier: MIT diff --git a/tests/manage.py b/tests/manage.py new file mode 100755 index 0000000..1c29b37 --- /dev/null +++ b/tests/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + 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?", + ) + execute_from_command_line(sys.argv) diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..b1c1d1d --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,71 @@ +"""Django settings for tests.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +ALLOWED_HOSTS: list[str] = [] + +BASE_DIR = Path(__file__).resolve().parent + +DEBUG_ENV = os.environ.get("DEBUG") +DEBUG = True + +DATABASE_NAME = BASE_DIR / "db.sqlite3" + +DATABASES: dict[str, dict[str, Any]] = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": DATABASE_NAME, + }, +} + +INSTALLED_APPS = [ + # Third Party + "django_admin_tui", + # Contrib + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.sessions", + "django.contrib.contenttypes", + "django.contrib.staticfiles", + "django.contrib.messages", + # Local + "test_app", +] + +MIDDLEWARE: list[str] = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", +] + +ROOT_URLCONF = "urls" + +SECRET_KEY = "NOTASECRET" # noqa: S105 + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "DIRS": [BASE_DIR / "templates" / "django"], + "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", + ] + }, + }, +] + +USE_TZ = True + +STATICFILES_DIRS = [ + BASE_DIR / "static", +] + +STATIC_URL = "/static/" diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app/admin.py b/tests/test_app/admin.py new file mode 100644 index 0000000..cb73451 --- /dev/null +++ b/tests/test_app/admin.py @@ -0,0 +1,17 @@ +"""Admin for the test app.""" + +from django.contrib import admin +from test_app.models import Bar +from test_app.models import Foo + + +@admin.register(Foo) +class FooAdmin(admin.ModelAdmin): + """Admin for the Foo model.""" + + +@admin.register(Bar) +class BarAdmin(admin.ModelAdmin): + """Admin for the Bar model.""" + + list_display = ("title", "foo__name") diff --git a/tests/test_app/migrations/0001_initial.py b/tests/test_app/migrations/0001_initial.py new file mode 100644 index 0000000..c29c92a --- /dev/null +++ b/tests/test_app/migrations/0001_initial.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.1 on 2024-09-12 10:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Foo', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ], + ), + ] diff --git a/tests/test_app/migrations/0002_bar.py b/tests/test_app/migrations/0002_bar.py new file mode 100644 index 0000000..4ff4312 --- /dev/null +++ b/tests/test_app/migrations/0002_bar.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.1 on 2024-09-13 16:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('test_app', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Bar', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('foo', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='test_app.foo')), + ], + ), + ] diff --git a/tests/test_app/migrations/__init__.py b/tests/test_app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app/models.py b/tests/test_app/models.py new file mode 100644 index 0000000..b7e56d2 --- /dev/null +++ b/tests/test_app/models.py @@ -0,0 +1,23 @@ +"""Models for the test app.""" + +from django.db import models + + +class Foo(models.Model): + """Simple model.""" + + name = models.CharField(max_length=255) + + def __str__(self) -> str: + return self.name + + +class Bar(models.Model): + """A model to test related fields.""" + + title = models.CharField(max_length=255) + + foo = models.ForeignKey(Foo, on_delete=models.CASCADE) + + def __str__(self) -> str: + return self.title diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..ea5aa3e --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,8 @@ +"""URLs for testing purposes.""" + +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +]