├── cbv ├── __init__.py ├── importer │ ├── __init__.py │ ├── dataclasses.py │ ├── storages.py │ └── importers.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── load_all_django_versions.py │ │ ├── populate_cbv.py │ │ ├── cbv_dumpversion.py │ │ └── fetch_docs_urls.py ├── migrations │ ├── __init__.py │ ├── 0005_delete_moduleattribute.py │ ├── 0008_delete_project.py │ ├── 0007_remove_project_fk.py │ ├── 0006_simplify_unique_constraint.py │ ├── 0003_remove_function_model.py │ ├── 0002_auto_20161106_0952.py │ ├── 0004_larger_attribute_values.py │ ├── 0009_move_unique_constraint.py │ ├── 0010_auto_20230104_0019.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ └── cbv_tags.py ├── static │ ├── favicon │ │ ├── favicon.ico │ │ └── favicon.svg │ ├── permalinks.js │ ├── ccbv.js │ ├── bootstrap-dropdowns.js │ ├── manni.css │ ├── style.css │ ├── bootstrap-tooltip.js │ ├── modernizr-2.5.3.min.js │ └── bootstrap-responsive.css ├── shortcut_urls.py ├── templates │ ├── sitemap.xml │ ├── 404.html │ ├── 500.html │ ├── cbv │ │ ├── module_detail.html │ │ ├── _klass_list.html │ │ ├── version_detail.html │ │ ├── includes │ │ │ └── nav.html │ │ └── klass_detail.html │ ├── base.html │ └── home.html ├── urls.py ├── queries.py ├── views.py └── models.py ├── core ├── __init__.py ├── wsgi.py ├── urls.py └── settings.py ├── tests ├── __init__.py ├── files │ ├── empty-sitemap.xml │ └── populated-sitemap.xml ├── factories.py ├── test_models.py ├── test_views.py ├── test_page_snapshots.py └── _page_snapshots │ ├── module-detail.html │ └── fuzzy-module-detail.html ├── .github ├── dependabot.yml └── workflows │ └── python-tests.yml ├── requirements.dev.in ├── requirements.prod.in ├── manage.py ├── .gitignore ├── .editorconfig ├── README.md ├── .pre-commit-config.yaml ├── license.md ├── Makefile ├── pyproject.toml ├── requirements.dev.txt ├── mypy-ratchet.json ├── requirements.prod.txt └── CONTRIBUTING.md /cbv/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cbv/importer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cbv/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cbv/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cbv/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cbv/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cbv/static/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classy-python/ccbv/HEAD/cbv/static/favicon/favicon.ico -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | -------------------------------------------------------------------------------- /requirements.dev.in: -------------------------------------------------------------------------------- 1 | --constraint requirements.prod.txt 2 | 3 | coverage[toml] 4 | factory_boy 5 | mypy 6 | mypy-json-report>=0.1.3 7 | pre-commit 8 | pytest 9 | pytest-django 10 | types-requests 11 | -------------------------------------------------------------------------------- /requirements.prod.in: -------------------------------------------------------------------------------- 1 | attrs>=21.4.0 2 | blessings 3 | django 4 | django-extensions 5 | django-pygmy 6 | django-sans-db>=1.2.0 7 | environs[django] 8 | gunicorn 9 | requests 10 | sphinx 11 | werkzeug 12 | whitenoise 13 | -------------------------------------------------------------------------------- /tests/files/empty-sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | /1.0 4 | 5 | -------------------------------------------------------------------------------- /cbv/shortcut_urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from cbv import views 4 | 5 | 6 | urlpatterns = [ 7 | path( 8 | "/", 9 | views.LatestKlassRedirectView.as_view(), 10 | name="klass-detail-shortcut", 11 | ), 12 | ] 13 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *~ 3 | .*.swp 4 | \#*# 5 | /secrets.py 6 | .DS_Store 7 | ._* 8 | *.egg 9 | *.egg-info 10 | /MANIFEST 11 | /_build 12 | /build 13 | /dist 14 | /eggs 15 | tests/test.zip 16 | /docs/_build 17 | *.sqlite 18 | *.db 19 | staticfiles 20 | .coverage 21 | .env 22 | .pytest_cache 23 | htmlcov 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.diff] 14 | trim_trailing_whitespace = false 15 | 16 | [*.json] 17 | indent_size = 1 18 | 19 | [*.yml] 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /cbv/migrations/0005_delete_moduleattribute.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-12-02 00:12 2 | from django.db import migrations 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | ("cbv", "0004_larger_attribute_values"), 8 | ] 9 | operations = [ 10 | migrations.DeleteModel(name="ModuleAttribute"), 11 | ] 12 | -------------------------------------------------------------------------------- /cbv/migrations/0008_delete_project.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-12-29 13:57 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("cbv", "0007_remove_project_fk"), 9 | ] 10 | 11 | operations = [ 12 | migrations.DeleteModel( 13 | name="Project", 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /tests/files/populated-sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | /1.0/projects/Django/42.0/module.name/Klass/0.9/projects/Django/41.0/module.name/Klass/0.5 4 | 5 | -------------------------------------------------------------------------------- /cbv/templates/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% spaceless %} 4 | {% for url in urlset %} 5 | 6 | {{ url.location }} 7 | {% if url.priority %}{{ url.priority }}{% endif %} 8 | 9 | {% endfor %} 10 | {% endspaceless %} 11 | 12 | -------------------------------------------------------------------------------- /cbv/migrations/0007_remove_project_fk.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-12-29 13:47 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("cbv", "0006_simplify_unique_constraint"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="projectversion", 14 | name="project", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /core/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for CCBV. 3 | It exposes the WSGI callable as a module-level variable named ``application``. 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ 6 | """ 7 | 8 | import os 9 | 10 | from django.core.wsgi import get_wsgi_application 11 | 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") 14 | 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /cbv/migrations/0006_simplify_unique_constraint.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-12-29 13:46 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("cbv", "0005_delete_moduleattribute"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterUniqueTogether( 13 | name="projectversion", 14 | unique_together={("version_number",)}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /cbv/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |

7 | 404 8 |

9 |

10 | The page you are looking for can't be found. Try our Homepage. 11 |

12 |
13 |
14 | {% endblock content %} 15 | -------------------------------------------------------------------------------- /cbv/migrations/0003_remove_function_model.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.11 on 2018-03-14 22:57 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("cbv", "0002_auto_20161106_0952"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="function", 14 | name="module", 15 | ), 16 | migrations.DeleteModel( 17 | name="Function", 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /cbv/management/commands/load_all_django_versions.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | 4 | from django.core.management import BaseCommand, call_command 5 | 6 | 7 | class Command(BaseCommand): 8 | """Load the Django project fixtures and all version fixtures""" 9 | 10 | def handle(self, **options): 11 | version_fixtures = glob.glob(os.path.join("cbv", "fixtures", "*.*.*json")) 12 | for fixture in version_fixtures: 13 | self.stdout.write(f"Loading {fixture}") 14 | call_command("loaddata", fixture) 15 | -------------------------------------------------------------------------------- /cbv/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |

7 | 500 8 |

9 |

10 | Oops, look like we broke something. We received a notification 11 | and will fix it as soon as possible. In the meantime, you can 12 | go back to our Homepage. 13 |

14 |
15 |
16 | {% endblock content %} 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Classy Class Based Views](https://ccbv.co.uk) 2 | 3 | Django's Generic class-based views provide a lot of common functionality to users. 4 | However, they are made up of up to twelve parent classes or mixins, at the time of writing. 5 | Knowing what functionality comes from which parent class/mixin is a tricky proposition. 6 | 7 | CCBV provides a breakdown of each view's inheritance hierarchy, methods, and attributes. 8 | This allows you to find this information without tracing through every file and base class. 9 | 10 | Interested in helping out? Check out our [contributor docs](CONTRIBUTING.md)! 11 | -------------------------------------------------------------------------------- /cbv/static/permalinks.js: -------------------------------------------------------------------------------- 1 | // Auto-expand the accordion and jump to the target if url contains a valid hash 2 | (function(doc){ 3 | "use strict"; 4 | 5 | $(function(event) { 6 | var hash = window.location.hash; 7 | var headerHeight = $('.navbar').height(); 8 | 9 | if (hash) { 10 | var $hdr = $(hash).parent('.accordion-group'); 11 | $hdr.prop('open', true); 12 | 13 | // Scroll it into view 14 | var methodTop = $hdr.offset().top - headerHeight - 8; 15 | $('html, body').animate({scrollTop: methodTop}, 250); 16 | } 17 | }); 18 | })(document); 19 | -------------------------------------------------------------------------------- /cbv/templates/cbv/module_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block title %}{{ module_name }}{% endblock %} 5 | 6 | 7 | {% block nav %} 8 | {% include "cbv/includes/nav.html" with nav=nav version_switcher=version_switcher only %} 9 | {% endblock nav %} 10 | 11 | {% block page_header %}

{{ module_name }}

{% endblock %} 12 | 13 | 14 | {% block content %} 15 |
16 | 21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | default_language_version: 3 | python: python3.13 4 | repos: 5 | - repo: https://github.com/adamchainz/django-upgrade 6 | rev: 1.24.0 7 | hooks: 8 | - id: django-upgrade 9 | args: [--target-version, "5.2"] 10 | 11 | - repo: https://github.com/ambv/black 12 | rev: 24.4.2 13 | hooks: 14 | - id: black 15 | 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.11.7 18 | hooks: 19 | - id: ruff 20 | 21 | - repo: https://github.com/asottile/pyupgrade 22 | rev: v3.19.1 23 | hooks: 24 | - id: pyupgrade 25 | args: 26 | - --py313-plus 27 | -------------------------------------------------------------------------------- /cbv/migrations/0002_auto_20161106_0952.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.6 on 2016-11-06 09:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("cbv", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="projectversion", 14 | options={"ordering": ("-sortable_version_number",)}, 15 | ), 16 | migrations.AddField( 17 | model_name="projectversion", 18 | name="sortable_version_number", 19 | field=models.CharField(blank=True, max_length=200), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /cbv/migrations/0004_larger_attribute_values.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-03-15 22:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("cbv", "0003_remove_function_model"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="klassattribute", 14 | name="value", 15 | field=models.CharField(max_length=511), 16 | ), 17 | migrations.AlterField( 18 | model_name="moduleattribute", 19 | name="value", 20 | field=models.CharField(max_length=511), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /cbv/migrations/0009_move_unique_constraint.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-12-29 18:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("cbv", "0008_delete_project"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterUniqueTogether( 13 | name="projectversion", 14 | unique_together=set(), 15 | ), 16 | migrations.AddConstraint( 17 | model_name="projectversion", 18 | constraint=models.UniqueConstraint( 19 | fields=("version_number",), name="unique_number_per_version" 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /cbv/management/commands/populate_cbv.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.conf import settings 3 | from django.core.management.base import BaseCommand 4 | 5 | from cbv.importer.importers import InspectCodeImporter 6 | from cbv.importer.storages import DBStorage 7 | 8 | 9 | class Command(BaseCommand): 10 | args = "" 11 | help = "Wipes and populates the CBV inspection models." 12 | 13 | def handle(self, *args, **options): 14 | module_paths = settings.CBV_SOURCES.keys() 15 | importer = InspectCodeImporter(module_paths=module_paths) 16 | 17 | DBStorage().import_project_version( 18 | importer=importer, 19 | project_version=django.get_version(), 20 | ) 21 | -------------------------------------------------------------------------------- /core/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.urls import include, path 4 | from django.views.generic import TemplateView 5 | 6 | from cbv.views import BasicHealthcheck, HomeView, Sitemap 7 | 8 | 9 | urlpatterns = [ 10 | path("", HomeView.as_view(), name="home"), 11 | path("projects/", include("cbv.urls")), 12 | path("sitemap.xml", Sitemap.as_view(), name="sitemap"), 13 | path("", include("cbv.shortcut_urls")), 14 | path("-/basic/", BasicHealthcheck.as_view()), 15 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 16 | 17 | 18 | if settings.DEBUG: 19 | urlpatterns += [ 20 | path("404/", TemplateView.as_view(template_name="404.html")), 21 | path("500/", TemplateView.as_view(template_name="500.html")), 22 | ] 23 | -------------------------------------------------------------------------------- /cbv/templates/cbv/_klass_list.html: -------------------------------------------------------------------------------- 1 |
2 | {% for obj in object_list %} 3 | {% ifchanged obj.module_name %} 4 | {% if not forloop.first %}
{% endif %} 5 | {% if obj.module_short_name == 'detail'%}
{% endif %} 6 |
7 |
17 |
18 | -------------------------------------------------------------------------------- /cbv/importer/dataclasses.py: -------------------------------------------------------------------------------- 1 | """Data classes used in the process of importing code.""" 2 | 3 | import abc 4 | 5 | import attr 6 | 7 | 8 | class CodeElement(abc.ABC): 9 | """A base for classes used to represent code.""" 10 | 11 | 12 | @attr.frozen 13 | class Klass(CodeElement): 14 | name: str 15 | module: str 16 | docstring: str 17 | line_number: int 18 | path: str 19 | bases: list[str] 20 | best_import_path: str 21 | 22 | 23 | @attr.frozen 24 | class KlassAttribute(CodeElement): 25 | name: str 26 | value: str 27 | line_number: int 28 | klass_path: str 29 | 30 | 31 | @attr.frozen 32 | class Method(CodeElement): 33 | name: str 34 | code: str 35 | docstring: str 36 | kwargs: list[str] 37 | line_number: int 38 | klass_path: str 39 | 40 | 41 | @attr.frozen 42 | class Module(CodeElement): 43 | name: str 44 | docstring: str 45 | filename: str 46 | -------------------------------------------------------------------------------- /cbv/templatetags/cbv_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.filter 8 | def namesake_methods(parent_klass, name): 9 | namesakes = [m for m in parent_klass.get_methods() if m.name == name] 10 | assert namesakes 11 | # Get the methods in order of the klasses 12 | try: 13 | result = [next(m for m in namesakes if m.klass == parent_klass)] 14 | namesakes.pop(namesakes.index(result[0])) 15 | except StopIteration: 16 | result = [] 17 | for klass in parent_klass.get_all_ancestors(): 18 | # Move the namesakes from the methods to the results 19 | try: 20 | method = next(m for m in namesakes if m.klass == klass) 21 | namesakes.pop(namesakes.index(method)) 22 | result.append(method) 23 | except StopIteration: 24 | pass 25 | assert not namesakes 26 | return result 27 | -------------------------------------------------------------------------------- /.github/workflows/python-tests.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Clone code 11 | uses: actions/checkout@v4 12 | 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.13" 17 | cache: "pip" 18 | cache-dependency-path: requirements.*.txt 19 | 20 | - name: Install Python dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -r requirements.prod.txt -r requirements.dev.txt 24 | 25 | - name: Run pre-commit 26 | run: | 27 | pre-commit run --all-files --show-diff-on-failure 28 | 29 | - name: Run Python tests 30 | run: | 31 | make test 32 | 33 | - name: Check mypy ratchet for changes 34 | run: | 35 | make mypy 36 | -------------------------------------------------------------------------------- /cbv/migrations/0010_auto_20230104_0019.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2023-01-04 00:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("cbv", "0009_move_unique_constraint")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="klass", 12 | name="docstring", 13 | field=models.TextField(default=""), 14 | ), 15 | migrations.AlterField( 16 | model_name="method", 17 | name="docstring", 18 | field=models.TextField(default=""), 19 | ), 20 | migrations.AlterField( 21 | model_name="module", 22 | name="docstring", 23 | field=models.TextField(default=""), 24 | ), 25 | migrations.AlterField( 26 | model_name="projectversion", 27 | name="sortable_version_number", 28 | field=models.CharField(max_length=200), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from cbv.models import Inheritance, Klass, Module, ProjectVersion 4 | 5 | 6 | class ProjectVersionFactory(factory.django.DjangoModelFactory): 7 | class Meta: 8 | model = ProjectVersion 9 | 10 | version_number = factory.Sequence(str) 11 | 12 | 13 | class ModuleFactory(factory.django.DjangoModelFactory): 14 | class Meta: 15 | model = Module 16 | 17 | project_version = factory.SubFactory(ProjectVersionFactory) 18 | name = factory.Sequence(lambda n: f"module{n}") 19 | 20 | 21 | class KlassFactory(factory.django.DjangoModelFactory): 22 | class Meta: 23 | model = Klass 24 | 25 | module = factory.SubFactory(ModuleFactory) 26 | name = factory.Sequence("klass{}".format) 27 | line_number = 1 28 | import_path = factory.LazyAttribute(lambda a: f"Django.{a.module.name}") 29 | 30 | 31 | class InheritanceFactory(factory.django.DjangoModelFactory): 32 | class Meta: 33 | model = Inheritance 34 | 35 | parent = factory.SubFactory(KlassFactory) 36 | child = factory.SubFactory(KlassFactory) 37 | order = 1 38 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Charles Denton and Marc Tamlyn 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | @echo "Usage:" 3 | @echo " make help: prints this help." 4 | @echo " make test: runs the tests." 5 | @echo " make build: install as for a deployed environment." 6 | @echo " make run-prod: run webserver as in deployed environment." 7 | @echo " make compile: compile the requirements specs." 8 | 9 | _uv: 10 | # ensure uv is installed 11 | pip install uv 12 | 13 | test: 14 | coverage run -m pytest -vvv tests 15 | coverage report 16 | 17 | mypy: 18 | mypy . | mypy-json-report > mypy-ratchet.json 19 | git diff --exit-code mypy-ratchet.json 20 | 21 | build: _uv 22 | uv pip install -r requirements.prod.txt -r requirements.dev.txt 23 | rm -rf staticfiles/* 24 | python manage.py collectstatic --no-input 25 | rm -f ccbv.sqlite 26 | python manage.py migrate 27 | python manage.py load_all_django_versions 28 | 29 | build-prod: 30 | pip install -r requirements.prod.txt 31 | rm -rf staticfiles/* 32 | python manage.py collectstatic --no-input 33 | rm -f ccbv.sqlite 34 | python manage.py migrate 35 | python manage.py load_all_django_versions 36 | 37 | run-prod: 38 | gunicorn core.wsgi --log-file - 39 | 40 | compile: _uv 41 | uv pip compile --quiet requirements.prod.in --output-file=requirements.prod.txt 42 | uv pip compile --quiet requirements.dev.in --output-file=requirements.dev.txt 43 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.coverage.run] 2 | branch = true 3 | dynamic_context = "test_function" 4 | 5 | [tool.coverage.report] 6 | show_missing = true 7 | skip_covered = true 8 | exclude_lines = [ 9 | # Protocol classes are abstract, and don't need coverage. 10 | "nocoverage: protocol", 11 | ] 12 | 13 | [tool.coverage.html] 14 | show_contexts = true 15 | 16 | [tool.mypy] 17 | check_untyped_defs = true 18 | disallow_incomplete_defs = true 19 | disallow_untyped_calls = true 20 | disallow_untyped_decorators = true 21 | disallow_untyped_defs = true 22 | ignore_missing_imports = true 23 | many_errors_threshold = -1 24 | no_implicit_optional = true 25 | show_error_codes = true 26 | warn_redundant_casts = true 27 | warn_unused_configs = true 28 | warn_unused_ignores = true 29 | 30 | [tool.pytest.ini_options] 31 | DJANGO_SETTINGS_MODULE = "core.settings" 32 | python_files = [ 33 | "test_*.py", 34 | ] 35 | 36 | [tool.ruff] 37 | extend-exclude = [ 38 | ".env", 39 | ] 40 | target-version = "py313" 41 | 42 | [tool.ruff.lint] 43 | extend-select = [ 44 | "A", # flake8-builtins 45 | "I", # isort 46 | "INP", # flake8-no-pep420 47 | "ISC", # flake8-implicit-str-concat 48 | "UP", # pyupgrade 49 | "W", # pycodestyle warning 50 | ] 51 | extend-ignore = [ 52 | "E731", # allow lambda assignment 53 | ] 54 | 55 | [tool.ruff.lint.isort] 56 | lines-after-imports = 2 57 | -------------------------------------------------------------------------------- /cbv/static/ccbv.js: -------------------------------------------------------------------------------- 1 | // Namespace CCBV functionality 2 | var CCBV = { 3 | klass_list: function() { return { 4 | /* Methods relating to klass lists */ 5 | get_secondary_klasses: function () { 6 | /* Get lists containing only secondary klasses, 7 | and
  • s with secondary klasses from lists with primary as well. */ 8 | secondary_lists = $('.klass-list:not(:has(li.primary))'); 9 | other_secondary_lis = $('.klass-list').not(secondary_lists).find('li.secondary'); 10 | return $.merge(secondary_lists, other_secondary_lis); 11 | }, 12 | hide_secondary: function () { 13 | this.get_secondary_klasses().hide(); 14 | }, 15 | toggle_secondary: function () { 16 | var klasses = this.get_secondary_klasses(); 17 | if (!klasses.is(':animated')){ 18 | klasses.slideToggle(); 19 | } 20 | return klasses; 21 | } 22 | };}(), 23 | 24 | method_list: function() { return { 25 | /* Methods related to method list in a class definition */ 26 | get_methods: function() { 27 | return $('#method-list details'); 28 | }, 29 | collapse: function() { 30 | var methods = this.get_methods(); 31 | methods.prop('open', false); 32 | return methods; 33 | }, 34 | expand: function() { 35 | var methods = this.get_methods(); 36 | methods.prop('open', true); 37 | return methods; 38 | } 39 | };}() 40 | }; 41 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements.dev.in --output-file=requirements.dev.txt 3 | cfgv==3.3.1 4 | # via pre-commit 5 | coverage==7.8.0 6 | # via -r requirements.dev.in 7 | distlib==0.3.9 8 | # via virtualenv 9 | factory-boy==3.2.0 10 | # via -r requirements.dev.in 11 | faker==9.3.1 12 | # via factory-boy 13 | filelock==3.18.0 14 | # via virtualenv 15 | identify==2.3.0 16 | # via pre-commit 17 | iniconfig==1.1.1 18 | # via pytest 19 | mypy==1.15.0 20 | # via -r requirements.dev.in 21 | mypy-extensions==1.1.0 22 | # via mypy 23 | mypy-json-report==0.1.3 24 | # via -r requirements.dev.in 25 | nodeenv==1.6.0 26 | # via pre-commit 27 | packaging==25.0 28 | # via 29 | # -c requirements.prod.txt 30 | # pytest 31 | platformdirs==4.3.7 32 | # via virtualenv 33 | pluggy==1.5.0 34 | # via pytest 35 | pre-commit==4.2.0 36 | # via -r requirements.dev.in 37 | pytest==8.3.5 38 | # via 39 | # -r requirements.dev.in 40 | # pytest-django 41 | pytest-django==4.11.1 42 | # via -r requirements.dev.in 43 | python-dateutil==2.8.2 44 | # via faker 45 | pyyaml==6.0.2 46 | # via pre-commit 47 | six==1.16.0 48 | # via 49 | # -c requirements.prod.txt 50 | # python-dateutil 51 | text-unidecode==1.3 52 | # via faker 53 | types-requests==2.27.7 54 | # via -r requirements.dev.in 55 | types-urllib3==1.26.7 56 | # via types-requests 57 | typing-extensions==4.13.2 58 | # via mypy 59 | virtualenv==20.30.0 60 | # via pre-commit 61 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .factories import InheritanceFactory, KlassFactory 4 | 5 | 6 | @pytest.mark.django_db 7 | class TestKlassAncestorMRO: 8 | def test_linear(self) -> None: 9 | """ 10 | Test a linear configuration of classes. C inherits from B which 11 | inherits from A. 12 | 13 | A 14 | | 15 | B 16 | | 17 | C 18 | 19 | C.__mro__ would be [C, B, A]. 20 | """ 21 | a = KlassFactory.create(name="a") 22 | b = KlassFactory.create(name="b") 23 | c = KlassFactory.create(name="c") 24 | InheritanceFactory.create(parent=a, child=b) 25 | InheritanceFactory.create(parent=b, child=c) 26 | 27 | mro = c.get_all_ancestors() 28 | 29 | assert mro == [b, a] 30 | 31 | def test_diamond(self) -> None: 32 | r""" 33 | Test a diamond configuration of classes. This example has A as a parent 34 | of B and C, and D has B and C as parents. 35 | 36 | A 37 | / \ 38 | B C 39 | \ / 40 | D 41 | 42 | D.__mro__ would be [D, B, C, A]. 43 | """ 44 | a = KlassFactory.create(name="a") 45 | b = KlassFactory.create(name="b") 46 | c = KlassFactory.create(name="c") 47 | d = KlassFactory.create(name="d") 48 | InheritanceFactory.create(parent=a, child=b) 49 | InheritanceFactory.create(parent=a, child=c) 50 | InheritanceFactory.create(parent=b, child=d) 51 | InheritanceFactory.create(parent=c, child=d, order=2) 52 | 53 | mro = d.get_all_ancestors() 54 | 55 | assert mro == [b, c, a] 56 | -------------------------------------------------------------------------------- /cbv/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL variations: 3 | project 4 | project/version 5 | project/version/module 6 | project/version/module/class 7 | 8 | e.g. 9 | django 10 | django/1.41a 11 | django/1.41a/core 12 | django/1.41a/core/DjangoRuntimeWarning 13 | """ 14 | 15 | from django.urls import path, reverse_lazy 16 | from django.views.generic import RedirectView 17 | 18 | from cbv import views 19 | 20 | 21 | urlpatterns = [ 22 | path("", RedirectView.as_view(url=reverse_lazy("home"))), 23 | path( 24 | "Django/", 25 | views.RedirectToLatestVersionView.as_view(), 26 | {"url_name": "version-detail"}, 27 | ), 28 | path( 29 | "Django/latest/", 30 | views.RedirectToLatestVersionView.as_view(), 31 | {"url_name": "version-detail"}, 32 | name="latest-version-detail", 33 | ), 34 | path( 35 | "Django//", 36 | views.VersionDetailView.as_view(), 37 | name="version-detail", 38 | ), 39 | path( 40 | "Django/latest//", 41 | views.RedirectToLatestVersionView.as_view(), 42 | {"url_name": "module-detail"}, 43 | name="latest-module-detail", 44 | ), 45 | path( 46 | "Django///", 47 | views.ModuleDetailView.as_view(), 48 | name="module-detail", 49 | ), 50 | path( 51 | "Django/latest///", 52 | views.RedirectToLatestVersionView.as_view(), 53 | {"url_name": "klass-detail"}, 54 | name="latest-klass-detail", 55 | ), 56 | path( 57 | "Django////", 58 | views.KlassDetailView.as_view(), 59 | name="klass-detail", 60 | ), 61 | ] 62 | -------------------------------------------------------------------------------- /cbv/templates/cbv/version_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block title %}{{ project }}{% endblock %} 6 | 7 | 8 | {% block meta_description %} 9 | Class-based views in {{ project }}. 10 | {% endblock meta_description %} 11 | 12 | 13 | {% block nav %} 14 | {% include "cbv/includes/nav.html" with nav=nav version_switcher=version_switcher only %} 15 | {% endblock %} 16 | 17 | 18 | {% block content %} 19 |
    20 |
    21 | Show more 22 |
    23 |

    {{ project }}

    24 |
    25 | {% include 'cbv/_klass_list.html' with column_width=6 object_list=object_list only %} 26 |
    27 |
    28 | {% endblock %} 29 | 30 | 31 | {% block extra_js %} 32 | 33 | 34 | 51 | {% endblock %} 52 | 53 | -------------------------------------------------------------------------------- /cbv/management/commands/cbv_dumpversion.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from django.core import serializers 4 | from django.core.management.base import LabelCommand 5 | 6 | from cbv import models 7 | 8 | 9 | class Command(LabelCommand): 10 | """Dump the django cbv app data for a specific version.""" 11 | 12 | def handle_label(self, label, **options): 13 | querysets = ( 14 | # There will be only one ProjectVersion, so no need for ordering. 15 | models.ProjectVersion.objects.filter(version_number=label), 16 | models.Module.objects.filter( 17 | project_version__version_number=label 18 | ).order_by("name"), 19 | models.Klass.objects.filter( 20 | module__project_version__version_number=label 21 | ).order_by("module__name", "name"), 22 | models.KlassAttribute.objects.filter( 23 | klass__module__project_version__version_number=label 24 | ).order_by("klass__module__name", "klass__name", "name"), 25 | models.Method.objects.filter( 26 | klass__module__project_version__version_number=label 27 | ).order_by("klass__module__name", "klass__name", "name"), 28 | models.Inheritance.objects.filter( 29 | parent__module__project_version__version_number=label 30 | ).order_by("child__module__name", "child__name", "order"), 31 | ) 32 | objects = list(chain.from_iterable(querysets)) 33 | for obj in objects: 34 | obj.pk = None 35 | dump = serializers.serialize( 36 | "json", 37 | objects, 38 | indent=1, 39 | use_natural_primary_keys=True, 40 | use_natural_foreign_keys=True, 41 | ) 42 | self.stdout.write(dump) 43 | -------------------------------------------------------------------------------- /core/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from environs import Env 4 | 5 | 6 | env = Env() 7 | 8 | 9 | BASE_DIR = Path(__file__).resolve().parent.parent 10 | 11 | SECRET_KEY = env.str("SECRET_KEY", default="extra-super-secret-development-key") 12 | 13 | DEBUG = env.bool("DEBUG", default=False) 14 | 15 | ALLOWED_HOSTS = ["*"] 16 | 17 | 18 | INSTALLED_APPS = [ 19 | # Project 20 | "cbv", 21 | # Third Party Apps 22 | "django_extensions", 23 | "django_pygmy", 24 | "sans_db", 25 | # Django 26 | "django.contrib.auth", 27 | "django.contrib.contenttypes", 28 | "django.contrib.staticfiles", 29 | ] 30 | 31 | MIDDLEWARE = [ 32 | "django.middleware.security.SecurityMiddleware", 33 | "whitenoise.middleware.WhiteNoiseMiddleware", 34 | "django.middleware.common.CommonMiddleware", 35 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 36 | ] 37 | 38 | ROOT_URLCONF = "core.urls" 39 | 40 | TEMPLATES = [ 41 | { 42 | "BACKEND": "sans_db.template_backends.django_sans_db.DjangoTemplatesSansDB", 43 | "APP_DIRS": True, 44 | }, 45 | ] 46 | 47 | WSGI_APPLICATION = "core.wsgi.application" 48 | 49 | DATABASES = {"default": env.dj_db_url("DATABASE_URL", default="sqlite:///ccbv.sqlite")} 50 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 51 | 52 | LANGUAGE_CODE = "en" 53 | TIME_ZONE = "Europe/London" 54 | USE_I18N = False 55 | USE_TZ = False 56 | 57 | STATIC_ROOT = BASE_DIR / "staticfiles" 58 | STATIC_URL = "/static/" 59 | 60 | STORAGES = { 61 | "staticfiles": { 62 | "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", 63 | }, 64 | } 65 | 66 | CBV_SOURCES = { 67 | "django.views.generic": "Generic", 68 | "django.contrib.formtools.wizard.views": "Wizard", 69 | "django.contrib.auth.views": "Auth", 70 | "django.contrib.auth.mixins": "Auth", 71 | } 72 | -------------------------------------------------------------------------------- /cbv/templates/cbv/includes/nav.html: -------------------------------------------------------------------------------- 1 |
  • 17 | {% for module in nav.modules %} 18 | {% ifchanged module.source_name %} 19 |
  • 20 |
  • {{ module.source_name }}
  • 21 | {% endifchanged %} 22 | {% if module.classes|length == 1 %} 23 | {% with klass=module.classes.0 %} 24 |
  • 25 | {{ klass.name }} 26 |
  • 27 | {% endwith %} 28 | {% else %} 29 | 41 | {% endif %} 42 | {% endfor %} 43 | -------------------------------------------------------------------------------- /mypy-ratchet.json: -------------------------------------------------------------------------------- 1 | { 2 | "cbv/importer/importers.py": { 3 | "Call to untyped function \"get_code\" in typed context [no-untyped-call]": 1, 4 | "Function is missing a type annotation [no-untyped-def]": 1, 5 | "Function is missing a type annotation for one or more arguments [no-untyped-def]": 12 6 | }, 7 | "cbv/management/commands/cbv_dumpversion.py": { 8 | "Function is missing a type annotation [no-untyped-def]": 1 9 | }, 10 | "cbv/management/commands/fetch_docs_urls.py": { 11 | "Call to untyped function \"bless_prints\" in typed context [no-untyped-call]": 3, 12 | "Function is missing a type annotation [no-untyped-def]": 2 13 | }, 14 | "cbv/management/commands/load_all_django_versions.py": { 15 | "Function is missing a type annotation [no-untyped-def]": 1 16 | }, 17 | "cbv/management/commands/populate_cbv.py": { 18 | "Function is missing a type annotation [no-untyped-def]": 1 19 | }, 20 | "cbv/migrations/0001_initial.py": { 21 | "Need type annotation for \"dependencies\" (hint: \"dependencies: list[] = ...\") [var-annotated]": 1 22 | }, 23 | "cbv/models.py": { 24 | "\"Callable[[Klass], tuple[str, str, str, str]]\" has no attribute \"dependencies\" [attr-defined]": 1, 25 | "\"Callable[[Module], tuple[str, str, str]]\" has no attribute \"dependencies\" [attr-defined]": 1, 26 | "Missing return statement [return]": 1 27 | }, 28 | "cbv/templatetags/cbv_tags.py": { 29 | "Function is missing a type annotation [no-untyped-def]": 1 30 | }, 31 | "cbv/views.py": { 32 | "Call to untyped function \"get_fuzzy_object\" in typed context [no-untyped-call]": 1, 33 | "Call to untyped function \"get_object\" in typed context [no-untyped-call]": 1, 34 | "Call to untyped function \"get_precise_object\" in typed context [no-untyped-call]": 1, 35 | "Function is missing a return type annotation [no-untyped-def]": 1, 36 | "Function is missing a type annotation [no-untyped-def]": 9, 37 | "Function is missing a type annotation for one or more arguments [no-untyped-def]": 1 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Protocol 3 | 4 | import pytest 5 | from django.test.client import Client 6 | from django.test.utils import CaptureQueriesContext 7 | from django.urls import reverse_lazy 8 | 9 | from .factories import KlassFactory, ProjectVersionFactory 10 | 11 | 12 | class AssertNumQueriesFixture(Protocol): # nocoverage: protocol 13 | def __call__(self, num: int, exact: bool = True) -> CaptureQueriesContext: ... 14 | 15 | 16 | @pytest.mark.django_db 17 | class TestSitemap: 18 | url = reverse_lazy("sitemap") 19 | 20 | def test_200(self, client: Client) -> None: 21 | ProjectVersionFactory.create() 22 | 23 | response = client.get(self.url) 24 | 25 | assert response.status_code == 200 26 | assert response["Content-Type"] == "application/xml" 27 | 28 | def test_queries( 29 | self, client: Client, django_assert_num_queries: AssertNumQueriesFixture 30 | ) -> None: 31 | KlassFactory.create() 32 | with django_assert_num_queries(2): # Get ProjectVersion, get Klasses. 33 | client.get(self.url) 34 | 35 | def test_empty_content(self, client: Client) -> None: 36 | ProjectVersionFactory.create() 37 | 38 | response = client.get(self.url) 39 | 40 | filename = "tests/files/empty-sitemap.xml" 41 | assert response.content.decode() == Path(filename).read_text() 42 | 43 | def test_populated_content(self, client: Client) -> None: 44 | KlassFactory.create( 45 | name="Klass", 46 | module__name="module.name", 47 | module__project_version__version_number="41.0", 48 | ) 49 | KlassFactory.create( 50 | name="Klass", 51 | module__name="module.name", 52 | module__project_version__version_number="42.0", 53 | ) 54 | 55 | response = client.get(self.url) 56 | 57 | filename = "tests/files/populated-sitemap.xml" 58 | assert response.content.decode() == Path(filename).read_text() 59 | 60 | 61 | class TestBasicHealthcheck: 62 | def test_200(self, client: Client) -> None: 63 | response = client.get("/-/basic/") 64 | 65 | assert response.status_code == 200 66 | -------------------------------------------------------------------------------- /requirements.prod.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements.prod.in --output-file=requirements.prod.txt 3 | alabaster==1.0.0 4 | # via sphinx 5 | asgiref==3.8.1 6 | # via django 7 | attrs==21.4.0 8 | # via -r requirements.prod.in 9 | babel==2.17.0 10 | # via sphinx 11 | blessings==1.7 12 | # via -r requirements.prod.in 13 | certifi==2025.4.26 14 | # via requests 15 | charset-normalizer==2.0.7 16 | # via requests 17 | dj-database-url==0.5.0 18 | # via environs 19 | dj-email-url==1.0.6 20 | # via environs 21 | django==5.2 22 | # via 23 | # -r requirements.prod.in 24 | # django-extensions 25 | django-cache-url==3.4.5 26 | # via environs 27 | django-extensions==3.1.3 28 | # via -r requirements.prod.in 29 | django-pygmy==0.1.5 30 | # via -r requirements.prod.in 31 | django-sans-db==1.2.0 32 | # via -r requirements.prod.in 33 | docutils==0.21.2 34 | # via sphinx 35 | environs==14.1.1 36 | # via -r requirements.prod.in 37 | gunicorn==23.0.0 38 | # via -r requirements.prod.in 39 | idna==3.10 40 | # via requests 41 | imagesize==1.4.1 42 | # via sphinx 43 | jinja2==3.1.6 44 | # via sphinx 45 | markupsafe==3.0.2 46 | # via 47 | # jinja2 48 | # werkzeug 49 | marshmallow==3.18.0 50 | # via environs 51 | packaging==25.0 52 | # via 53 | # gunicorn 54 | # marshmallow 55 | # sphinx 56 | pygments==2.19.1 57 | # via 58 | # django-pygmy 59 | # sphinx 60 | python-dotenv==1.1.0 61 | # via environs 62 | requests==2.32.3 63 | # via 64 | # -r requirements.prod.in 65 | # sphinx 66 | six==1.16.0 67 | # via blessings 68 | snowballstemmer==2.2.0 69 | # via sphinx 70 | sphinx==8.1.3 71 | # via -r requirements.prod.in 72 | sphinxcontrib-applehelp==2.0.0 73 | # via sphinx 74 | sphinxcontrib-devhelp==2.0.0 75 | # via sphinx 76 | sphinxcontrib-htmlhelp==2.1.0 77 | # via sphinx 78 | sphinxcontrib-jsmath==1.0.1 79 | # via sphinx 80 | sphinxcontrib-qthelp==2.0.0 81 | # via sphinx 82 | sphinxcontrib-serializinghtml==2.0.0 83 | # via sphinx 84 | sqlparse==0.5.3 85 | # via django 86 | urllib3==2.4.0 87 | # via requests 88 | werkzeug==3.1.3 89 | # via -r requirements.prod.in 90 | whitenoise==6.9.0 91 | # via -r requirements.prod.in 92 | -------------------------------------------------------------------------------- /cbv/static/favicon/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /cbv/management/commands/fetch_docs_urls.py: -------------------------------------------------------------------------------- 1 | import posixpath 2 | 3 | import requests 4 | from blessings import Terminal 5 | from django.conf import settings 6 | from django.core.management.base import BaseCommand 7 | from sphinx.util.inventory import InventoryFile 8 | 9 | from cbv.models import Klass, ProjectVersion 10 | 11 | 12 | t = Terminal() 13 | 14 | 15 | class Command(BaseCommand): 16 | args = "" 17 | help = "Fetches the docs urls for CBV Classes." 18 | # Django no longer hosts docs for < 1.7, so we only want versions that 19 | # are both in CCBV and at least as recent as 1.7 20 | django_versions = ProjectVersion.objects.exclude( 21 | sortable_version_number__lt="0107" 22 | ).values_list("version_number", flat=True) 23 | # Django has custom inventory file name 24 | inv_filename = "_objects" 25 | 26 | def bless_prints(self, version, msg): 27 | # wish the blessings lib supports method chaining.. 28 | a = t.blue(f"Django {version}: ") 29 | z = t.green(msg) 30 | print(a + z) 31 | 32 | def handle(self, *args, **options): 33 | """ 34 | Docs urls for Classes can differ between Django versions. 35 | This script sets correct urls for specific Classes using bits from 36 | `sphinx.ext.intersphinx` to fetch docs inventory data. 37 | """ 38 | 39 | for v in self.django_versions: 40 | cnt = 1 41 | 42 | ver_url = f"https://docs.djangoproject.com/en/{v}" 43 | ver_inv_url = ver_url + "/" + self.inv_filename 44 | 45 | # get flat list of CBV classes per Django version 46 | qs_lookups = {"module__project_version__version_number": v} 47 | ver_classes = Klass.objects.filter(**qs_lookups).values_list( 48 | "name", flat=True 49 | ) 50 | self.bless_prints(v, f"Found {len(ver_classes)} classes") 51 | self.bless_prints(v, f"Getting inventory @ {ver_inv_url}") 52 | # fetch some inventory dataz 53 | # the arg `r.raw` should be a Sphinx instance object.. 54 | r = requests.get(ver_inv_url, stream=True) 55 | r.raise_for_status() 56 | invdata = InventoryFile.load(r.raw, ver_url, posixpath.join) 57 | # we only want classes.. 58 | for item in invdata["py:class"]: 59 | # ..which come from one of our sources 60 | if any(source in item for source in settings.CBV_SOURCES.keys()): 61 | # get class name 62 | inv_klass = item.split(".")[-1] 63 | # save hits to db and update only required classes 64 | for vc in ver_classes: 65 | if vc == inv_klass: 66 | url = invdata["py:class"][item][2] 67 | qs_lookups.update({"name": inv_klass}) 68 | Klass.objects.filter(**qs_lookups).update(docs_url=url) 69 | cnt += 1 70 | continue 71 | self.bless_prints(v, f"Updated {cnt} classes\n") 72 | -------------------------------------------------------------------------------- /cbv/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% load static %} 3 | 4 | 5 | 6 | {% block title %}Django Class-Based-View Inspector{% endblock %} -- Classy CBV 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% if canonical_url %} 21 | 22 | {% endif %} 23 | 24 | {% if push_state_url %} 25 | 28 | {% endif %} 29 | 30 | {% block extraheaders %}{% endblock %} 31 | 32 | 33 | 43 |
    44 |
    45 | {% block page_header %}{% endblock %} 46 |
    {% block content %}{% endblock %}
    47 |
    48 | {% block footer %} 49 | 56 | {% endblock %} 57 |
    58 | 59 | 60 | 61 | 62 | 63 | 64 | {% block extra_js %}{% endblock %} 65 | 66 | 67 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | We maintain tags on [our issues](https://github.com/classy-python/ccbv/issues/) to make it easy to find ones that might suit newcomers to the project. 3 | The [Low-hanging fruit tag](https://github.com/classy-python/ccbv/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22Low-hanging%20fruit%22) is a good place to start if you're unfamiliar with the project. 4 | 5 | > [!NOTE] 6 | > TLDR: The project is currently undergoing an overhaul behind the scenes with the goal of removing the need to use Django to serve pages. 7 | > Check that your changes are still relevant with that in mind! 8 | > 9 | > CCBV runs as a Django site, pulling data from a database. 10 | > This made it very fast to get up and running, and easy to maintain for the Django-using developers, but it has been a thorn in the side of the project for years. 11 | > The dataset is entirely fixed. 12 | > Any changes to Django's generic class based views (GCBVs) only happen when Django makes a new release. 13 | > We do not need to dynamically construct templates from the data on every request. 14 | > We can write out some HTML and never touch it again (unless we feel like changing the site's styles!) 15 | > The inspection code is tightly coupled to Django's GCBVs. 16 | > There have been sites for other Django-specific class hierarchies using forks of CCBV for years. 17 | > Other class hierarchies exist in Python. 18 | > Work has been ongoing to reduce the coupling of the site to Django, with the goal of eventually completely removing it. 19 | > This will help both this project and any related ones to more quickly update after Django or library releases, and also open up opportunities for other projects to grow. 20 | 21 | ## Installation 22 | Set up a virtualenv and run: 23 | 24 | make build 25 | 26 | This will install the requirements, collect static files, migrate the database, and finally load all the existing fixtures into your database. 27 | Afterwards, you can run 28 | 29 | make run-prod 30 | 31 | to start the webserver. 32 | 33 | ## Updating requirements 34 | Add or remove the dependency from either `requirements.prod.in` or `requirements.dev.in` as appropriate. 35 | 36 | Run `make compile` and appropriate txt file will be updated. 37 | 38 | ## Add data for new versions of Django 39 | 1. Update the `requirements.prod.in` file to pin the new version of Django, eg `django==5.1` 40 | 1. Run `make compile` to compile this change to `requirements.prod.txt` 41 | 1. Run `python manage.py populate_cbv` to introspect the installed Django and populate the required objects in the database 42 | 1. Run `python manage.py fetch_docs_urls` to update the records in the database with the latest links to the Django documentation, this will fail at 1.9, this is expected 43 | 1. Export the new Django version into a fixture with `python manage.py cbv_dumpversion x.xx > cbv/fixtures/x.xx.json` 44 | 1. Remove the empty Generic module from the generated JSON 45 | 1. Add the fixture to git with `git add cbv/fixtures/.git` 46 | 1. Restore the requirements files with `git restore requirements.*` 47 | 1. Commit and push your changes, they will be deployed once your PR is merged to main 48 | 49 | ## Testing 50 | Run `make test` to run the full test suite with coverage. 51 | -------------------------------------------------------------------------------- /cbv/static/bootstrap-dropdowns.js: -------------------------------------------------------------------------------- 1 | /* ============================================================ 2 | * bootstrap-dropdown.js v2.0.3 3 | * http://twitter.github.com/bootstrap/javascript.html#dropdowns 4 | * ============================================================ 5 | * Copyright 2012 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ============================================================ */ 19 | 20 | 21 | !function ($) { 22 | 23 | "use strict"; // jshint ;_; 24 | 25 | 26 | /* DROPDOWN CLASS DEFINITION 27 | * ========================= */ 28 | 29 | var toggle = '[data-toggle="dropdown"]' 30 | , Dropdown = function (element) { 31 | var $el = $(element).on('click.dropdown.data-api', this.toggle) 32 | $('html').on('click.dropdown.data-api', function () { 33 | $el.parent().removeClass('open') 34 | }) 35 | } 36 | 37 | Dropdown.prototype = { 38 | 39 | constructor: Dropdown 40 | 41 | , toggle: function (e) { 42 | var $this = $(this) 43 | , $parent 44 | , selector 45 | , isActive 46 | 47 | if ($this.is('.disabled, :disabled')) return 48 | 49 | selector = $this.attr('data-target') 50 | 51 | if (!selector) { 52 | selector = $this.attr('href') 53 | selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') //strip for ie7 54 | } 55 | 56 | $parent = $(selector) 57 | $parent.length || ($parent = $this.parent()) 58 | 59 | isActive = $parent.hasClass('open') 60 | 61 | clearMenus() 62 | 63 | if (!isActive) $parent.toggleClass('open') 64 | 65 | return false 66 | } 67 | 68 | } 69 | 70 | function clearMenus() { 71 | $(toggle).parent().removeClass('open') 72 | } 73 | 74 | 75 | /* DROPDOWN PLUGIN DEFINITION 76 | * ========================== */ 77 | 78 | $.fn.dropdown = function (option) { 79 | return this.each(function () { 80 | var $this = $(this) 81 | , data = $this.data('dropdown') 82 | if (!data) $this.data('dropdown', (data = new Dropdown(this))) 83 | if (typeof option == 'string') data[option].call($this) 84 | }) 85 | } 86 | 87 | $.fn.dropdown.Constructor = Dropdown 88 | 89 | 90 | /* APPLY TO STANDARD DROPDOWN ELEMENTS 91 | * =================================== */ 92 | 93 | $(function () { 94 | $('html').on('click.dropdown.data-api', clearMenus) 95 | $('body') 96 | .on('click.dropdown', '.dropdown form', function (e) { e.stopPropagation() }) 97 | .on('click.dropdown.data-api', toggle, Dropdown.prototype.toggle) 98 | }) 99 | 100 | }(window.jQuery); 101 | -------------------------------------------------------------------------------- /cbv/queries.py: -------------------------------------------------------------------------------- 1 | import attrs 2 | 3 | from cbv import models 4 | 5 | 6 | @attrs.frozen 7 | class VersionSwitcher: 8 | version_name: str 9 | other_versions: list["OtherVersion"] 10 | project_name: str = "Django" 11 | 12 | @attrs.frozen 13 | class OtherVersion: 14 | name: str 15 | url: str 16 | 17 | 18 | @attrs.frozen 19 | class NavData: 20 | modules: list["Module"] 21 | 22 | @attrs.frozen 23 | class Module: 24 | source_name: str 25 | short_name: str 26 | classes: list["NavData.Klass"] 27 | active: bool 28 | 29 | @attrs.frozen 30 | class Klass: 31 | name: str 32 | url: str 33 | active: bool 34 | 35 | 36 | class NavBuilder: 37 | def _to_module_data( 38 | self, 39 | module: models.Module, 40 | active_module: models.Module | None, 41 | active_klass: models.Klass | None, 42 | ) -> "NavData.Module": 43 | return NavData.Module( 44 | source_name=module.source_name(), 45 | short_name=module.short_name(), 46 | classes=[ 47 | NavData.Klass( 48 | name=klass.name, 49 | url=klass.get_absolute_url(), 50 | active=klass == active_klass, 51 | ) 52 | for klass in module.klass_set.all() 53 | ], 54 | active=module == active_module, 55 | ) 56 | 57 | def make_version_switcher( 58 | self, 59 | project_version: models.ProjectVersion, 60 | klass: models.Klass | None = None, 61 | ) -> VersionSwitcher: 62 | other_versions = models.ProjectVersion.objects.exclude(pk=project_version.pk) 63 | if klass: 64 | other_versions_of_klass = models.Klass.objects.filter( 65 | name=klass.name, 66 | module__project_version__in=other_versions, 67 | ) 68 | other_versions_of_klass_dict = { 69 | x.module.project_version: x for x in other_versions_of_klass 70 | } 71 | versions = [] 72 | for other_version in other_versions: 73 | try: 74 | other_klass = other_versions_of_klass_dict[other_version] 75 | except KeyError: 76 | url = other_version.get_absolute_url() 77 | else: 78 | url = other_klass.get_absolute_url() 79 | 80 | versions.append( 81 | VersionSwitcher.OtherVersion( 82 | name=other_version.version_number, url=url 83 | ) 84 | ) 85 | else: 86 | versions = [ 87 | VersionSwitcher.OtherVersion( 88 | name=other_version.version_number, 89 | url=other_version.get_absolute_url(), 90 | ) 91 | for other_version in other_versions 92 | ] 93 | 94 | version_switcher = VersionSwitcher( 95 | version_name=project_version.version_number, 96 | other_versions=versions, 97 | ) 98 | return version_switcher 99 | 100 | def get_nav_data( 101 | self, 102 | project_version: models.ProjectVersion, 103 | module: models.Module | None = None, 104 | klass: models.Klass | None = None, 105 | ) -> NavData: 106 | module_set = project_version.module_set.prefetch_related("klass_set").order_by( 107 | "name" 108 | ) 109 | modules = [ 110 | self._to_module_data(module=m, active_module=module, active_klass=klass) 111 | for m in module_set 112 | ] 113 | nav_data = NavData(modules=modules) 114 | return nav_data 115 | -------------------------------------------------------------------------------- /cbv/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block nav %} 6 | {% include "cbv/includes/nav.html" with nav=nav version_switcher=version_switcher only %} 7 | {% endblock %} 8 | 9 | 10 | {% block content %} 11 |
    12 |

    Classy Class-Based Views.

    13 |

    Detailed 14 | descriptions, with full methods and attributes, for each of 15 | Django's class-based generic views. 16 |

    17 |
    18 |
    19 |
    20 |

    Did you know?

    21 |

    22 | ccbv.co.uk/ClassName/ 23 | will take you straight to the class you're looking for. 24 |

    25 |
    26 |
    27 |
    28 |
    29 |
    30 | Show more 31 |
    32 |

    Start Here for {{ project }}.

    33 |
    34 | {% include 'cbv/_klass_list.html' with column_width=3 object_list=object_list only %} 35 |
    36 |
    37 |
    38 |
    39 |

    What are class-based views anyway?

    40 |

    41 | Django's class-based generic views provide abstract classes 42 | implementing common web development tasks. These are very powerful, 43 | and heavily-utilise Python's object orientation and multiple 44 | inheritance in order to be extensible. This means they're more than 45 | just a couple of generic shortcuts — they provide utilities 46 | which can be mixed into the much more complex views that you write 47 | yourself. 48 |

    49 | 50 |

    Great! So what's the problem?

    51 |

    52 | All of this power comes at the expense of simplicity. For example, 53 | trying to work out exactly which method you need to customise, and 54 | what its keyword arguments are, on your UpdateView 55 | can feel a little like wading through spaghetti — it has 10 56 | separate ancestors (plus object), spread across 3 57 | different python files. This site shows you exactly what you need 58 | to know. 59 |

    60 | 61 |

    How does this site help?

    62 |

    63 | To make things easier, we've taken all the attributes and methods 64 | that every view defines or inherits, and flattened all that 65 | information onto one comprehensive page per view. Check out 66 | UpdateView, 67 | for example. 68 |

    69 |
    70 | {% endblock %} 71 | 72 | 73 | {% block extra_js %} 74 | 75 | 76 | 93 | {% endblock %} 94 | -------------------------------------------------------------------------------- /cbv/static/manni.css: -------------------------------------------------------------------------------- 1 | /* from https://raw.github.com/richleland/pygments-css/master/manni.css and tweaked */ 2 | 3 | .highlight .hll { background-color: #ffffcc } 4 | .highlight .c { color: #0099FF; font-style: italic } /* Comment */ 5 | .highlight .err { color: #AA0000; background-color: #FFAAAA } /* Error */ 6 | .highlight .k { color: #006699; font-weight: bold } /* Keyword */ 7 | .highlight .o { color: #555555 } /* Operator */ 8 | .highlight .cm { color: #0099FF; font-style: italic } /* Comment.Multiline */ 9 | .highlight .cp { color: #009999 } /* Comment.Preproc */ 10 | .highlight .c1 { color: #0099FF; font-style: italic } /* Comment.Single */ 11 | .highlight .cs { color: #0099FF; font-weight: bold; font-style: italic } /* Comment.Special */ 12 | .highlight .gd { background-color: #FFCCCC; border: 1px solid #CC0000 } /* Generic.Deleted */ 13 | .highlight .ge { font-style: italic } /* Generic.Emph */ 14 | .highlight .gr { color: #FF0000 } /* Generic.Error */ 15 | .highlight .gh { color: #003300; font-weight: bold } /* Generic.Heading */ 16 | .highlight .gi { background-color: #CCFFCC; border: 1px solid #00CC00 } /* Generic.Inserted */ 17 | .highlight .go { color: #AAAAAA } /* Generic.Output */ 18 | .highlight .gp { color: #000099; font-weight: bold } /* Generic.Prompt */ 19 | .highlight .gs { font-weight: bold } /* Generic.Strong */ 20 | .highlight .gu { color: #003300; font-weight: bold } /* Generic.Subheading */ 21 | .highlight .gt { color: #99CC66 } /* Generic.Traceback */ 22 | .highlight .kc { color: #006699; font-weight: bold } /* Keyword.Constant */ 23 | .highlight .kd { color: #006699; font-weight: bold } /* Keyword.Declaration */ 24 | .highlight .kn { color: #006699; font-weight: bold } /* Keyword.Namespace */ 25 | .highlight .kp { color: #006699 } /* Keyword.Pseudo */ 26 | .highlight .kr { color: #006699; font-weight: bold } /* Keyword.Reserved */ 27 | .highlight .kt { color: #007788; font-weight: bold } /* Keyword.Type */ 28 | .highlight .m { color: #FF6600 } /* Literal.Number */ 29 | .highlight .s { color: #CC3300 } /* Literal.String */ 30 | .highlight .na { color: #330099 } /* Name.Attribute */ 31 | .highlight .nb { color: #336666 } /* Name.Builtin */ 32 | .highlight .nc { color: #00AA88; font-weight: bold } /* Name.Class */ 33 | .highlight .no { color: #336600 } /* Name.Constant */ 34 | .highlight .nd { color: #9999FF } /* Name.Decorator */ 35 | .highlight .ni { color: #999999; font-weight: bold } /* Name.Entity */ 36 | .highlight .ne { color: #CC0000; font-weight: bold } /* Name.Exception */ 37 | .highlight .nf { color: #0088CC } /* Name.Function */ 38 | .highlight .nl { color: #9999FF } /* Name.Label */ 39 | .highlight .nn { color: #00CCFF; font-weight: bold } /* Name.Namespace */ 40 | .highlight .nt { color: #330099; font-weight: bold } /* Name.Tag */ 41 | .highlight .nv { color: #003333 } /* Name.Variable */ 42 | .highlight .ow { color: #000000; font-weight: bold } /* Operator.Word */ 43 | .highlight .w { color: #bbbbbb } /* Text.Whitespace */ 44 | .highlight .mf { color: #FF6600 } /* Literal.Number.Float */ 45 | .highlight .mh { color: #FF6600 } /* Literal.Number.Hex */ 46 | .highlight .mi { color: #FF6600 } /* Literal.Number.Integer */ 47 | .highlight .mo { color: #FF6600 } /* Literal.Number.Oct */ 48 | .highlight .sb { color: #CC3300 } /* Literal.String.Backtick */ 49 | .highlight .sc { color: #CC3300 } /* Literal.String.Char */ 50 | .highlight .sd { color: #CC3300; font-style: italic } /* Literal.String.Doc */ 51 | .highlight .s2 { color: #CC3300 } /* Literal.String.Double */ 52 | .highlight .se { color: #CC3300; font-weight: bold } /* Literal.String.Escape */ 53 | .highlight .sh { color: #CC3300 } /* Literal.String.Heredoc */ 54 | .highlight .si { color: #AA0000 } /* Literal.String.Interpol */ 55 | .highlight .sx { color: #CC3300 } /* Literal.String.Other */ 56 | .highlight .sr { color: #33AAAA } /* Literal.String.Regex */ 57 | .highlight .s1 { color: #CC3300 } /* Literal.String.Single */ 58 | .highlight .ss { color: #FFCC33 } /* Literal.String.Symbol */ 59 | .highlight .bp { color: #336666 } /* Name.Builtin.Pseudo */ 60 | .highlight .vc { color: #003333 } /* Name.Variable.Class */ 61 | .highlight .vg { color: #003333 } /* Name.Variable.Global */ 62 | .highlight .vi { color: #003333 } /* Name.Variable.Instance */ 63 | .highlight .il { color: #FF6600 } /* Literal.Number.Integer.Long */ 64 | -------------------------------------------------------------------------------- /cbv/static/style.css: -------------------------------------------------------------------------------- 1 | html {overflow-y: scroll;} 2 | #logo { 3 | color: #888; 4 | font-size: 50px; 5 | margin-bottom: 30px; 6 | } 7 | .nav h3 { 8 | color: #333; 9 | } 10 | .skinny { 11 | padding: 8px 0; 12 | } 13 | .well.klass-list { 14 | min-height: auto; 15 | } 16 | #klass-buttons { 17 | display: none; 18 | float: right; 19 | } 20 | .secondary a { 21 | color: #51a351; 22 | } 23 | .docstring { 24 | margin: 10px 0 0; 25 | padding: 0 0 0 1px; 26 | background-color: transparent; 27 | border: none; 28 | } 29 | .highlighttable { 30 | margin-top: 10px; 31 | width: 100%; 32 | } 33 | .highlighttable td { 34 | padding: 0; 35 | } 36 | .highlighttable td.linenos { 37 | width: 50px; 38 | text-align: right; 39 | } 40 | .highlighttable td:nth-of-type(1) pre { 41 | background-color: #eee; 42 | border-top-right-radius: 0; 43 | border-bottom-right-radius: 0; 44 | } 45 | .highlighttable td:nth-of-type(2) pre { 46 | border-left: none; 47 | border-top-left-radius: 0; 48 | border-bottom-left-radius: 0; 49 | } 50 | .highlighttable pre { 51 | white-space: preserve nowrap; 52 | } 53 | .highlighttable td.code pre { 54 | max-width: calc(100vw - 100px); 55 | display: block; 56 | overflow-x: auto; 57 | } 58 | .page-header .docstring { 59 | margin-bottom: 0; 60 | } 61 | code.attribute { 62 | border: none; 63 | color: #000; 64 | background-color: transparent; 65 | } 66 | code.attribute.overridden { 67 | text-decoration: line-through; 68 | } 69 | .signature { 70 | border: none; 71 | color: #333; 72 | } 73 | .signature.highlight { 74 | background-color: transparent; 75 | padding: 0; 76 | } 77 | #method-buttons { 78 | float: right; 79 | } 80 | .method { 81 | margin-bottom: 10px; 82 | } 83 | .method.accordion-group { 84 | border: none; 85 | } 86 | .method.accordion-group summary::-webkit-details-marker { 87 | display: none; 88 | } 89 | .method.accordion-group summary.btn { 90 | border-bottom: 1px solid #ccc; 91 | display: block; 92 | text-align: left; 93 | padding: 0 10px; 94 | overflow: hidden; 95 | } 96 | .method.accordion-group summary h3 code { 97 | font-size: 16px; 98 | } 99 | .method.accordion-group summary small { 100 | padding-top: 3px; 101 | } 102 | .method.accordion-group .permalink { 103 | opacity: 0.0; 104 | font-size: 0.75em; 105 | transition: opacity 0.5s; 106 | } 107 | .method.accordion-group:focus-within .permalink, 108 | .method.accordion-group:hover .permalink { 109 | opacity: 1.0; 110 | } 111 | .namesake.accordion-group { 112 | margin-top: 10px; 113 | } 114 | .namesake .accordion-heading { 115 | display: block; 116 | background-color: #F9F9F9; 117 | } 118 | .attribute pre { 119 | background-color: #eee; 120 | } 121 | #descendants.span8 ul { 122 | -moz-column-count:2; /* Firefox */ 123 | -webkit-column-count:2; /* Safari and Chrome */ 124 | column-count:2; 125 | } 126 | #descendants.span12 ul { 127 | -moz-column-count:3; /* Firefox */ 128 | -webkit-column-count:3; /* Safari and Chrome */ 129 | column-count:3; 130 | } 131 | #ancestors .direct { 132 | font-weight: bold; 133 | } 134 | footer p { 135 | margin-bottom: 0; 136 | color: #555; 137 | text-align: right; 138 | } 139 | @media (max-width: 480px) { 140 | #descendants.span12 ul { 141 | -moz-column-count:1; /* Firefox */ 142 | -webkit-column-count:1; /* Safari and Chrome */ 143 | column-count:1; 144 | } 145 | } 146 | 147 | @media (min-width: 481px) and (max-width: 767px) { 148 | #descendants.span12 ul { 149 | -moz-column-count:2; /* Firefox */ 150 | -webkit-column-count:2; /* Safari and Chrome */ 151 | column-count:2; 152 | } 153 | } 154 | 155 | #main{ 156 | padding-top: 60px; 157 | } 158 | 159 | .nav .nav-header{ 160 | padding: 0 15px; 161 | } 162 | .http-error { 163 | text-align: center; 164 | margin-bottom: 3em; 165 | } 166 | .http-error-number { 167 | font-size: 18em; 168 | line-height: 1em; 169 | margin: 0 auto; 170 | width: 400px; 171 | } 172 | .http-error-message { 173 | font-size: 2.5em; 174 | text-align: justify; 175 | line-height: 1.4em; 176 | margin: 0 auto; 177 | width: 400px; 178 | } 179 | @media (max-width: 480px) { 180 | .http-error-number { 181 | margin-top: -30px; 182 | font-size: 9em; 183 | width: 200px; 184 | } 185 | .http-error-message { 186 | font-size: 1.5em; 187 | width: 200px; 188 | } 189 | } 190 | @media (min-width: 768px) and (max-width: 1200px) { 191 | .klass-list { 192 | overflow-x: auto; 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /tests/test_page_snapshots.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from django.core.management import call_command 5 | from django.test.client import Client 6 | from django.urls import reverse 7 | from pytest_django.asserts import assertHTMLEqual, assertNumQueries 8 | from pytest_django.fixtures import SettingsWrapper 9 | 10 | 11 | parameters = [ 12 | ( 13 | "homepage.html", 14 | 5, 15 | reverse("home"), 16 | ), 17 | ( 18 | "version-detail.html", 19 | 5, 20 | reverse("version-detail", kwargs={"version": "4.0"}), 21 | ), 22 | ( 23 | "module-detail.html", 24 | 7, 25 | reverse( 26 | "module-detail", 27 | kwargs={ 28 | "version": "4.0", 29 | "module": "django.views.generic.edit", 30 | }, 31 | ), 32 | ), 33 | ( 34 | "klass-detail.html", 35 | 30, 36 | reverse( 37 | "klass-detail", 38 | kwargs={ 39 | "version": "4.0", 40 | "module": "django.views.generic.edit", 41 | "klass": "FormView", 42 | }, 43 | ), 44 | ), 45 | ( 46 | "klass-detail-old.html", 47 | 30, 48 | reverse( 49 | "klass-detail", 50 | kwargs={ 51 | "version": "3.2", 52 | "module": "django.views.generic.edit", 53 | "klass": "FormView", 54 | }, 55 | ), 56 | ), 57 | # Detail pages with wRonGLY CasEd arGuMEnTs 58 | ( 59 | "fuzzy-module-detail.html", 60 | 9, 61 | reverse( 62 | "module-detail", 63 | kwargs={ 64 | "version": "4.0", 65 | "module": "DJANGO.VIEWS.GENERIC.EDIT", 66 | }, 67 | ), 68 | ), 69 | ( 70 | "fuzzy-klass-detail.html", 71 | 30, 72 | reverse( 73 | "klass-detail", 74 | kwargs={ 75 | "version": "4.0", 76 | "module": "DJANGO.VIEWS.GENERIC.EDIT", 77 | "klass": "fORMvIEW", 78 | }, 79 | ), 80 | ), 81 | ( 82 | "fuzzy-klass-detail-old.html", 83 | 30, 84 | reverse( 85 | "klass-detail", 86 | kwargs={ 87 | "version": "3.2", 88 | "module": "django.VIEWS.generic.EDIT", 89 | "klass": "fOrMvIeW", 90 | }, 91 | ), 92 | ), 93 | ] 94 | 95 | 96 | @pytest.mark.parametrize( 97 | ["filename", "num_queries", "url"], 98 | parameters, 99 | ids=[ 100 | "homepage", 101 | "version-detail.html", 102 | "module-detail.html", 103 | "klass-detail.html", 104 | "klass-detail-old.html", 105 | "fuzzy-module-detail.html", 106 | "fuzzy-klass-detail.html", 107 | "fuzzy-klass-detail-old.html", 108 | ], 109 | ) 110 | @pytest.mark.django_db 111 | def test_page_html( 112 | client: Client, 113 | settings: SettingsWrapper, 114 | tmp_path: Path, 115 | filename: str, 116 | num_queries: int, 117 | url: str, 118 | ) -> None: 119 | """ 120 | Checks that the pages in the array above match the reference files in tests/_page_snapshots/. 121 | 122 | This test is intended to prevent regressions when refactoring views/templates. 123 | As well as ensuring the HTML hasn't materially changed, 124 | we also check the number of queries made when rendering the page. 125 | 126 | If the reference files legitimately need to change, they can be 127 | re-generated by temporarily uncommenting the appropriate lines at the 128 | bottom of the test. 129 | """ 130 | # Load a couple of versions of Django. 131 | # It doesn't matter what they are, just that they stay consistent. 132 | call_command("loaddata", "3.2.json") 133 | call_command("loaddata", "4.0.json") 134 | 135 | # We set this so the subsequent call to collecstatic doesn't write to the 136 | # directory configured in settings.py 137 | settings.STATIC_ROOT = tmp_path 138 | 139 | # We call this so we can render the templates with a STATIC_URL and hash in 140 | # the path, to match what happens in production 141 | call_command("collectstatic", "--noinput") 142 | 143 | with assertNumQueries(num_queries): 144 | response = client.get(url) 145 | 146 | html = response.rendered_content 147 | path = Path("tests/_page_snapshots", filename) 148 | 149 | # Uncomment the below to re-generate the reference files when they need to 150 | # change for a legitimate reason. 151 | # DO NOT commit this uncommented! 152 | # path.write_text(html) 153 | 154 | expected = path.read_text() 155 | 156 | # This forces a useful error in the case of a mismatch. 157 | # We have to ignore the type because accessing __wrapped__ is pretty odd. 158 | assertHTMLEqual.__wrapped__.__self__.maxDiff = None # type: ignore 159 | assertHTMLEqual(html, expected) 160 | -------------------------------------------------------------------------------- /cbv/importer/storages.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from collections.abc import Mapping, Sequence 3 | 4 | from attrs import frozen 5 | 6 | from cbv import models 7 | from cbv.importer.dataclasses import Klass, KlassAttribute, Method, Module 8 | from cbv.importer.importers import CodeImporter 9 | 10 | 11 | class DBStorage: 12 | def import_project_version( 13 | self, *, importer: CodeImporter, project_version: str 14 | ) -> None: 15 | self._wipe_clashing_data(project_version=project_version) 16 | 17 | # Setup Project 18 | project_version_pk = models.ProjectVersion.objects.create( 19 | version_number=project_version, 20 | ).pk 21 | 22 | klasses = [] 23 | attributes: defaultdict[tuple[str, str], list[tuple[str, int]]] = defaultdict( 24 | list 25 | ) 26 | klass_models: dict[str, models.Klass] = {} 27 | module_models: dict[str, models.Module] = {} 28 | method_models: list[models.Method] = [] 29 | 30 | for member in importer.generate_code_data(): 31 | if isinstance(member, Module): 32 | module_model = models.Module.objects.create( 33 | project_version_id=project_version_pk, 34 | name=member.name, 35 | docstring=member.docstring, 36 | filename=member.filename, 37 | ) 38 | module_models[member.name] = module_model 39 | elif isinstance(member, KlassAttribute): 40 | attributes[(member.name, member.value)] += [ 41 | (member.klass_path, member.line_number) 42 | ] 43 | elif isinstance(member, Method): 44 | method = models.Method( 45 | klass=klass_models[member.klass_path], 46 | name=member.name, 47 | docstring=member.docstring, 48 | code=member.code, 49 | kwargs=member.kwargs, 50 | line_number=member.line_number, 51 | ) 52 | method_models.append(method) 53 | elif isinstance(member, Klass): 54 | klass_model = models.Klass.objects.create( 55 | module=module_models[member.module], 56 | name=member.name, 57 | docstring=member.docstring, 58 | line_number=member.line_number, 59 | import_path=member.best_import_path, 60 | ) 61 | klass_models[member.path] = klass_model 62 | klasses.append(member) 63 | 64 | models.Method.objects.bulk_create(method_models) 65 | create_inheritance(klasses, klass_models) 66 | create_attributes(attributes, klass_models) 67 | print("Stored:") 68 | print(f" Modules: {len(module_models)}") 69 | print(f" Classes: {len(klasses)}") 70 | print(f" Methods: {len(method_models)}") 71 | print(f" Attributes: {models.KlassAttribute.objects.count()}") 72 | 73 | def _wipe_clashing_data(self, *, project_version: str) -> None: 74 | """Delete existing data in the DB to make way for this new import.""" 75 | # We don't really care about deleting the ProjectVersion here in particular. 76 | # In fact, we'll re-create it later. 77 | # Instead, we're using the cascading delete to remove all the dependent objects. 78 | models.ProjectVersion.objects.filter( 79 | version_number=project_version, 80 | ).delete() 81 | models.Inheritance.objects.filter( 82 | parent__module__project_version__version_number=project_version, 83 | ).delete() 84 | 85 | 86 | @frozen 87 | class Attribute: 88 | klass_pk: int 89 | line_number: int 90 | name: str 91 | value: str 92 | 93 | def to_model(self) -> models.KlassAttribute: 94 | return models.KlassAttribute( 95 | klass_id=self.klass_pk, 96 | line_number=self.line_number, 97 | name=self.name, 98 | value=self.value, 99 | ) 100 | 101 | 102 | def create_attributes( 103 | attributes: Mapping[tuple[str, str], Sequence[tuple[str, int]]], 104 | klass_lookup: Mapping[str, models.Klass], 105 | ) -> None: 106 | # Go over each name/value pair to create KlassAttributes 107 | collected_attributes = set() 108 | for (name, value), klasses in attributes.items(): 109 | # Find all the descendants of each Klass. 110 | descendants = set() 111 | for klass_path, start_line in klasses: 112 | klass = klass_lookup[klass_path] 113 | for child in klass.get_all_children(): 114 | descendants.add(child) 115 | 116 | # By removing descendants from klasses, we leave behind the 117 | # klass(s) where the value was defined. 118 | remaining_klasses = [ 119 | k_and_l 120 | for k_and_l in klasses 121 | if klass_lookup[k_and_l[0]] not in descendants 122 | ] 123 | 124 | # Now we can create the KlassAttributes 125 | for klass_path, line in remaining_klasses: 126 | klass = klass_lookup[klass_path] 127 | 128 | collected_attributes.add( 129 | Attribute( 130 | klass_pk=klass.pk, 131 | line_number=line, 132 | name=name, 133 | value=value, 134 | ) 135 | ) 136 | 137 | models.KlassAttribute.objects.bulk_create( 138 | [attribute.to_model() for attribute in collected_attributes] 139 | ) 140 | 141 | 142 | def create_inheritance( 143 | klasses: Sequence[Klass], klass_lookup: Mapping[str, models.Klass] 144 | ) -> None: 145 | inheritance_models = [] 146 | for klass_data in klasses: 147 | direct_ancestors = klass_data.bases 148 | for i, ancestor in enumerate(direct_ancestors): 149 | if ancestor not in klass_lookup: 150 | continue 151 | inheritance_models.append( 152 | models.Inheritance( 153 | parent=klass_lookup[ancestor], 154 | child=klass_lookup[klass_data.path], 155 | order=i, 156 | ) 157 | ) 158 | models.Inheritance.objects.bulk_create(inheritance_models) 159 | -------------------------------------------------------------------------------- /cbv/templates/cbv/klass_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load pygmy %} 3 | {% load cbv_tags %} 4 | {% load i18n %} 5 | {% load static %} 6 | 7 | 8 | {% block title %}{{ klass.name }}{% endblock %} 9 | 10 | 11 | {% block meta_description %} 12 | {{ klass.name }} in {{ project }}. 13 | {% if klass.docstring %} 14 | {{ klass.docstring }} 15 | {% endif %} 16 | {% endblock meta_description %} 17 | 18 | 19 | {% block extra_js %} 20 | 32 | 33 | 34 | {% endblock %} 35 | 36 | 37 | {% block nav %} 38 | {% include "cbv/includes/nav.html" with nav=nav version_switcher=version_switcher only %} 39 | {% endblock nav %} 40 | 41 | 42 | {% block page_header %} 43 |

    class {{ klass.name }}

    44 |
    from {{ klass.import_path }} import {{ klass.name }}
    45 |
    46 | {% with url=yuml_url %} 47 | {% if url %} 48 | {% trans "Hierarchy diagram" %} 49 | {% else %} 50 | {% trans "Hierarchy diagram" %} 51 | {% endif %} 52 | {% endwith %} 53 | {% if klass.docs_url %} 54 | {% trans "Documentation" %} 55 | {% else %} 56 | {% trans "Documentation" %} 57 | {% endif %} 58 | {% trans "Source code" %} 59 |
    60 | {% if klass.docstring %} 61 |
    {{ klass.docstring }}
    62 | {% endif %} 63 | {% endblock %} 64 | 65 | 66 | {% block content %} 67 |
    68 |
    69 | {% if all_ancestors %} 70 |
    71 |

    Ancestors (MRO)

    72 |
      73 |
    1. {{ klass.name }}
    2. 74 | {% for ancestor in all_ancestors %} 75 |
    3. 76 | 77 | {{ ancestor.name }} 78 | 79 |
    4. 80 | {% endfor %} 81 |
    82 |
    83 | {% endif %} 84 | 85 | {% if all_children %} 86 |
    87 |

    Descendants

    88 |
      89 | {% for child in all_children %} 90 |
    • {{ child.name }}
    • 91 | {% endfor %} 92 |
    93 |
    94 | {% endif %} 95 |
    96 | 97 |
    98 | {% for attribute in attributes %} 99 | {% if forloop.first %} 100 |
    101 |

    Attributes

    102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | {% endif %} 111 | 112 | 117 | 124 | 125 | {% if forloop.last %} 126 | 127 |
     Defined in
    113 | 114 | {{ attribute.name }} = {{ attribute.value }} 115 | 116 | 118 | {% if attribute.klass == klass %} 119 | {{ attribute.klass.name }} 120 | {% else %} 121 | {{ attribute.klass.name }} 122 | {% endif %} 123 |
    128 |
    129 | {% endif %} 130 | {% endfor %} 131 |
    132 |
    133 | {% for method in methods %} 134 | {% if forloop.first %} 135 |
    136 |
    137 | Expand 138 | Collapse 139 |
    140 |

    Methods

    141 | {% endif %} 142 | {% ifchanged method.name %} 143 | {% with namesakes=klass|namesake_methods:method.name %} 144 |
    145 | 146 |

    147 | 148 | def 149 | {{ method.name }}({{ method.kwargs }}): 150 | 151 | {% if namesakes|length == 1 %} 152 | {{ method.klass.name }} 153 | {% endif %} 154 | 155 |

    156 |
    157 |
    158 | {% for namesake in namesakes %} 159 | {% if namesakes|length != 1 %} 160 |
    161 | 162 |

    {{ namesake.klass.name }}

    163 |
    164 |
    165 |
    166 | {% if namesake.docstring %}
    {{ namesake.docstring }}
    {% endif %} 167 | {% pygmy namesake.code linenos='True' linenostart=namesake.line_number lexer='python' %} 168 |
    169 |
    170 |
    171 | {% else %} 172 | {% if namesake.docstring %}
    {{ namesake.docstring }}
    {% endif %} 173 | {% pygmy namesake.code linenos='True' linenostart=namesake.line_number lexer='python' %} 174 | {% endif %} 175 | {% endfor %} 176 |
    177 |
    178 | {% endwith %} 179 | {% endifchanged %} 180 | {% if forloop.last %}
    {% endif %} 181 | {% endfor %} 182 |
    183 |
    184 | {% endblock %} 185 | -------------------------------------------------------------------------------- /cbv/static/bootstrap-tooltip.js: -------------------------------------------------------------------------------- 1 | /* =========================================================== 2 | * bootstrap-tooltip.js v2.0.4 3 | * http://twitter.github.com/bootstrap/javascript.html#tooltips 4 | * Inspired by the original jQuery.tipsy by Jason Frame 5 | * =========================================================== 6 | * Copyright 2012 Twitter, Inc. 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * ========================================================== */ 20 | 21 | 22 | !function ($) { 23 | 24 | "use strict"; // jshint ;_; 25 | 26 | 27 | /* TOOLTIP PUBLIC CLASS DEFINITION 28 | * =============================== */ 29 | 30 | var Tooltip = function (element, options) { 31 | this.init('tooltip', element, options) 32 | } 33 | 34 | Tooltip.prototype = { 35 | 36 | constructor: Tooltip 37 | 38 | , init: function (type, element, options) { 39 | var eventIn 40 | , eventOut 41 | 42 | this.type = type 43 | this.$element = $(element) 44 | this.options = this.getOptions(options) 45 | this.enabled = true 46 | 47 | if (this.options.trigger != 'manual') { 48 | eventIn = this.options.trigger == 'hover' ? 'mouseenter' : 'focus' 49 | eventOut = this.options.trigger == 'hover' ? 'mouseleave' : 'blur' 50 | this.$element.on(eventIn, this.options.selector, $.proxy(this.enter, this)) 51 | this.$element.on(eventOut, this.options.selector, $.proxy(this.leave, this)) 52 | } 53 | 54 | this.options.selector ? 55 | (this._options = $.extend({}, this.options, { trigger: 'manual', selector: '' })) : 56 | this.fixTitle() 57 | } 58 | 59 | , getOptions: function (options) { 60 | options = $.extend({}, $.fn[this.type].defaults, options, this.$element.data()) 61 | 62 | if (options.delay && typeof options.delay == 'number') { 63 | options.delay = { 64 | show: options.delay 65 | , hide: options.delay 66 | } 67 | } 68 | 69 | return options 70 | } 71 | 72 | , enter: function (e) { 73 | var self = $(e.currentTarget)[this.type](this._options).data(this.type) 74 | 75 | if (!self.options.delay || !self.options.delay.show) return self.show() 76 | 77 | clearTimeout(this.timeout) 78 | self.hoverState = 'in' 79 | this.timeout = setTimeout(function() { 80 | if (self.hoverState == 'in') self.show() 81 | }, self.options.delay.show) 82 | } 83 | 84 | , leave: function (e) { 85 | var self = $(e.currentTarget)[this.type](this._options).data(this.type) 86 | 87 | if (this.timeout) clearTimeout(this.timeout) 88 | if (!self.options.delay || !self.options.delay.hide) return self.hide() 89 | 90 | self.hoverState = 'out' 91 | this.timeout = setTimeout(function() { 92 | if (self.hoverState == 'out') self.hide() 93 | }, self.options.delay.hide) 94 | } 95 | 96 | , show: function () { 97 | var $tip 98 | , inside 99 | , pos 100 | , actualWidth 101 | , actualHeight 102 | , placement 103 | , tp 104 | 105 | if (this.hasContent() && this.enabled) { 106 | $tip = this.tip() 107 | this.setContent() 108 | 109 | if (this.options.animation) { 110 | $tip.addClass('fade') 111 | } 112 | 113 | placement = typeof this.options.placement == 'function' ? 114 | this.options.placement.call(this, $tip[0], this.$element[0]) : 115 | this.options.placement 116 | 117 | inside = /in/.test(placement) 118 | 119 | $tip 120 | .remove() 121 | .css({ top: 0, left: 0, display: 'block' }) 122 | .appendTo(inside ? this.$element : document.body) 123 | 124 | pos = this.getPosition(inside) 125 | 126 | actualWidth = $tip[0].offsetWidth 127 | actualHeight = $tip[0].offsetHeight 128 | 129 | switch (inside ? placement.split(' ')[1] : placement) { 130 | case 'bottom': 131 | tp = {top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2} 132 | break 133 | case 'top': 134 | tp = {top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2} 135 | break 136 | case 'left': 137 | tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth} 138 | break 139 | case 'right': 140 | tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width} 141 | break 142 | } 143 | 144 | $tip 145 | .css(tp) 146 | .addClass(placement) 147 | .addClass('in') 148 | } 149 | } 150 | 151 | , isHTML: function(text) { 152 | // html string detection logic adapted from jQuery 153 | return typeof text != 'string' 154 | || ( text.charAt(0) === "<" 155 | && text.charAt( text.length - 1 ) === ">" 156 | && text.length >= 3 157 | ) || /^(?:[^<]*<[\w\W]+>[^>]*$)/.exec(text) 158 | } 159 | 160 | , setContent: function () { 161 | var $tip = this.tip() 162 | , title = this.getTitle() 163 | 164 | $tip.find('.tooltip-inner')[this.isHTML(title) ? 'html' : 'text'](title) 165 | $tip.removeClass('fade in top bottom left right') 166 | } 167 | 168 | , hide: function () { 169 | var that = this 170 | , $tip = this.tip() 171 | 172 | $tip.removeClass('in') 173 | 174 | function removeWithAnimation() { 175 | var timeout = setTimeout(function () { 176 | $tip.off($.support.transition.end).remove() 177 | }, 500) 178 | 179 | $tip.one($.support.transition.end, function () { 180 | clearTimeout(timeout) 181 | $tip.remove() 182 | }) 183 | } 184 | 185 | $.support.transition && this.$tip.hasClass('fade') ? 186 | removeWithAnimation() : 187 | $tip.remove() 188 | } 189 | 190 | , fixTitle: function () { 191 | var $e = this.$element 192 | if ($e.attr('title') || typeof($e.attr('data-original-title')) != 'string') { 193 | $e.attr('data-original-title', $e.attr('title') || '').removeAttr('title') 194 | } 195 | } 196 | 197 | , hasContent: function () { 198 | return this.getTitle() 199 | } 200 | 201 | , getPosition: function (inside) { 202 | return $.extend({}, (inside ? {top: 0, left: 0} : this.$element.offset()), { 203 | width: this.$element[0].offsetWidth 204 | , height: this.$element[0].offsetHeight 205 | }) 206 | } 207 | 208 | , getTitle: function () { 209 | var title 210 | , $e = this.$element 211 | , o = this.options 212 | 213 | title = $e.attr('data-original-title') 214 | || (typeof o.title == 'function' ? o.title.call($e[0]) : o.title) 215 | 216 | return title 217 | } 218 | 219 | , tip: function () { 220 | return this.$tip = this.$tip || $(this.options.template) 221 | } 222 | 223 | , validate: function () { 224 | if (!this.$element[0].parentNode) { 225 | this.hide() 226 | this.$element = null 227 | this.options = null 228 | } 229 | } 230 | 231 | , enable: function () { 232 | this.enabled = true 233 | } 234 | 235 | , disable: function () { 236 | this.enabled = false 237 | } 238 | 239 | , toggleEnabled: function () { 240 | this.enabled = !this.enabled 241 | } 242 | 243 | , toggle: function () { 244 | this[this.tip().hasClass('in') ? 'hide' : 'show']() 245 | } 246 | 247 | } 248 | 249 | 250 | /* TOOLTIP PLUGIN DEFINITION 251 | * ========================= */ 252 | 253 | $.fn.tooltip = function ( option ) { 254 | return this.each(function () { 255 | var $this = $(this) 256 | , data = $this.data('tooltip') 257 | , options = typeof option == 'object' && option 258 | if (!data) $this.data('tooltip', (data = new Tooltip(this, options))) 259 | if (typeof option == 'string') data[option]() 260 | }) 261 | } 262 | 263 | $.fn.tooltip.Constructor = Tooltip 264 | 265 | $.fn.tooltip.defaults = { 266 | animation: true 267 | , placement: 'top' 268 | , selector: false 269 | , template: '
    ' 270 | , trigger: 'hover' 271 | , title: '' 272 | , delay: 0 273 | } 274 | 275 | }(window.jQuery); 276 | -------------------------------------------------------------------------------- /cbv/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [] 6 | 7 | operations = [ 8 | migrations.CreateModel( 9 | name="Function", 10 | fields=[ 11 | ( 12 | "id", 13 | models.AutoField( 14 | verbose_name="ID", 15 | serialize=False, 16 | auto_created=True, 17 | primary_key=True, 18 | ), 19 | ), 20 | ("name", models.CharField(max_length=200)), 21 | ("docstring", models.TextField(default="", blank=True)), 22 | ("code", models.TextField()), 23 | ("kwargs", models.CharField(max_length=200)), 24 | ("line_number", models.IntegerField()), 25 | ], 26 | options={ 27 | "ordering": ("name",), 28 | }, 29 | ), 30 | migrations.CreateModel( 31 | name="Inheritance", 32 | fields=[ 33 | ( 34 | "id", 35 | models.AutoField( 36 | verbose_name="ID", 37 | serialize=False, 38 | auto_created=True, 39 | primary_key=True, 40 | ), 41 | ), 42 | ("order", models.IntegerField()), 43 | ], 44 | options={ 45 | "ordering": ("order",), 46 | }, 47 | ), 48 | migrations.CreateModel( 49 | name="Klass", 50 | fields=[ 51 | ( 52 | "id", 53 | models.AutoField( 54 | verbose_name="ID", 55 | serialize=False, 56 | auto_created=True, 57 | primary_key=True, 58 | ), 59 | ), 60 | ("name", models.CharField(max_length=200)), 61 | ("docstring", models.TextField(default="", blank=True)), 62 | ("line_number", models.IntegerField()), 63 | ("import_path", models.CharField(max_length=255)), 64 | ("docs_url", models.URLField(default="", max_length=255)), 65 | ], 66 | options={ 67 | "ordering": ("module__name", "name"), 68 | }, 69 | ), 70 | migrations.CreateModel( 71 | name="KlassAttribute", 72 | fields=[ 73 | ( 74 | "id", 75 | models.AutoField( 76 | verbose_name="ID", 77 | serialize=False, 78 | auto_created=True, 79 | primary_key=True, 80 | ), 81 | ), 82 | ("name", models.CharField(max_length=200)), 83 | ("value", models.CharField(max_length=200)), 84 | ("line_number", models.IntegerField()), 85 | ( 86 | "klass", 87 | models.ForeignKey( 88 | on_delete=models.CASCADE, 89 | related_name="attribute_set", 90 | to="cbv.Klass", 91 | ), 92 | ), 93 | ], 94 | options={ 95 | "ordering": ("name",), 96 | }, 97 | ), 98 | migrations.CreateModel( 99 | name="Method", 100 | fields=[ 101 | ( 102 | "id", 103 | models.AutoField( 104 | verbose_name="ID", 105 | serialize=False, 106 | auto_created=True, 107 | primary_key=True, 108 | ), 109 | ), 110 | ("name", models.CharField(max_length=200)), 111 | ("docstring", models.TextField(default="", blank=True)), 112 | ("code", models.TextField()), 113 | ("kwargs", models.CharField(max_length=200)), 114 | ("line_number", models.IntegerField()), 115 | ("klass", models.ForeignKey(on_delete=models.CASCADE, to="cbv.Klass")), 116 | ], 117 | options={ 118 | "ordering": ("name",), 119 | }, 120 | ), 121 | migrations.CreateModel( 122 | name="Module", 123 | fields=[ 124 | ( 125 | "id", 126 | models.AutoField( 127 | verbose_name="ID", 128 | serialize=False, 129 | auto_created=True, 130 | primary_key=True, 131 | ), 132 | ), 133 | ("name", models.CharField(max_length=200)), 134 | ("docstring", models.TextField(default="", blank=True)), 135 | ("filename", models.CharField(default="", max_length=511)), 136 | ], 137 | ), 138 | migrations.CreateModel( 139 | name="ModuleAttribute", 140 | fields=[ 141 | ( 142 | "id", 143 | models.AutoField( 144 | verbose_name="ID", 145 | serialize=False, 146 | auto_created=True, 147 | primary_key=True, 148 | ), 149 | ), 150 | ("name", models.CharField(max_length=200)), 151 | ("value", models.CharField(max_length=200)), 152 | ("line_number", models.IntegerField()), 153 | ( 154 | "module", 155 | models.ForeignKey( 156 | on_delete=models.CASCADE, 157 | related_name="attribute_set", 158 | to="cbv.Module", 159 | ), 160 | ), 161 | ], 162 | options={ 163 | "ordering": ("name",), 164 | }, 165 | ), 166 | migrations.CreateModel( 167 | name="Project", 168 | fields=[ 169 | ( 170 | "id", 171 | models.AutoField( 172 | verbose_name="ID", 173 | serialize=False, 174 | auto_created=True, 175 | primary_key=True, 176 | ), 177 | ), 178 | ("name", models.CharField(unique=True, max_length=200)), 179 | ], 180 | ), 181 | migrations.CreateModel( 182 | name="ProjectVersion", 183 | fields=[ 184 | ( 185 | "id", 186 | models.AutoField( 187 | verbose_name="ID", 188 | serialize=False, 189 | auto_created=True, 190 | primary_key=True, 191 | ), 192 | ), 193 | ("version_number", models.CharField(max_length=200)), 194 | ( 195 | "project", 196 | models.ForeignKey(on_delete=models.CASCADE, to="cbv.Project"), 197 | ), 198 | ], 199 | options={ 200 | "ordering": ("-version_number",), 201 | }, 202 | ), 203 | migrations.AddField( 204 | model_name="module", 205 | name="project_version", 206 | field=models.ForeignKey(on_delete=models.CASCADE, to="cbv.ProjectVersion"), 207 | ), 208 | migrations.AddField( 209 | model_name="klass", 210 | name="module", 211 | field=models.ForeignKey(on_delete=models.CASCADE, to="cbv.Module"), 212 | ), 213 | migrations.AddField( 214 | model_name="inheritance", 215 | name="child", 216 | field=models.ForeignKey( 217 | on_delete=models.CASCADE, 218 | related_name="ancestor_relationships", 219 | to="cbv.Klass", 220 | ), 221 | ), 222 | migrations.AddField( 223 | model_name="inheritance", 224 | name="parent", 225 | field=models.ForeignKey(on_delete=models.CASCADE, to="cbv.Klass"), 226 | ), 227 | migrations.AddField( 228 | model_name="function", 229 | name="module", 230 | field=models.ForeignKey(on_delete=models.CASCADE, to="cbv.Module"), 231 | ), 232 | migrations.AlterUniqueTogether( 233 | name="projectversion", 234 | unique_together={("project", "version_number")}, 235 | ), 236 | migrations.AlterUniqueTogether( 237 | name="moduleattribute", 238 | unique_together={("module", "name")}, 239 | ), 240 | migrations.AlterUniqueTogether( 241 | name="module", 242 | unique_together={("project_version", "name")}, 243 | ), 244 | migrations.AlterUniqueTogether( 245 | name="klassattribute", 246 | unique_together={("klass", "name")}, 247 | ), 248 | migrations.AlterUniqueTogether( 249 | name="klass", 250 | unique_together={("module", "name")}, 251 | ), 252 | migrations.AlterUniqueTogether( 253 | name="inheritance", 254 | unique_together={("child", "order")}, 255 | ), 256 | ] 257 | -------------------------------------------------------------------------------- /cbv/importer/importers.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import sys 4 | import textwrap 5 | from collections.abc import Iterator 6 | from typing import Protocol 7 | 8 | import attr 9 | from django.core.exceptions import ImproperlyConfigured 10 | from django.utils.functional import Promise 11 | 12 | from cbv.importer.dataclasses import CodeElement, Klass, KlassAttribute, Method, Module 13 | 14 | 15 | BANNED_ATTR_NAMES = ( 16 | "__all__", 17 | "__builtins__", 18 | "__class__", 19 | "__dict__", 20 | "__doc__", 21 | "__file__", 22 | "__module__", 23 | "__name__", 24 | "__package__", 25 | "__path__", 26 | "__spec__", 27 | "__weakref__", 28 | ) 29 | 30 | 31 | class CodeImporter(Protocol): # nocoverage: protocol 32 | def generate_code_data(self) -> Iterator[CodeElement]: ... 33 | 34 | 35 | @attr.frozen 36 | class InspectCodeImporter: 37 | """Generates code structure classes by using the inspect module.""" 38 | 39 | module_paths: list[str] 40 | 41 | def generate_code_data(self) -> Iterator[CodeElement]: 42 | modules = [] 43 | for module_path in self.module_paths: 44 | try: 45 | modules.append(importlib.import_module(module_path)) 46 | except ImportError: 47 | pass 48 | 49 | for module in modules: 50 | module_name = module.__name__ 51 | 52 | yield from self._process_member( 53 | member=module, 54 | member_name=module_name, 55 | root_module_name=module_name, 56 | parent=None, 57 | ) 58 | 59 | def _process_member( 60 | self, *, member, member_name, root_module_name, parent 61 | ) -> Iterator[CodeElement]: 62 | # BUILTIN 63 | if inspect.isbuiltin(member): 64 | pass 65 | 66 | # MODULE 67 | elif inspect.ismodule(member): 68 | yield from self._handle_module(member, root_module_name) 69 | 70 | # CLASS 71 | elif inspect.isclass(member) and inspect.ismodule(parent): 72 | yield from self._handle_class_on_module(member, parent, root_module_name) 73 | 74 | # METHOD 75 | elif inspect.ismethod(member) or inspect.isfunction(member): 76 | yield from self._handle_function_or_method(member, member_name, parent) 77 | 78 | # (Class) ATTRIBUTE 79 | elif inspect.isclass(parent): 80 | yield from self._handle_class_attribute(member, member_name, parent) 81 | 82 | def _process_submembers(self, *, parent, root_module_name) -> Iterator[CodeElement]: 83 | for submember_name, submember_type in inspect.getmembers(parent): 84 | yield from self._process_member( 85 | member=submember_type, 86 | member_name=submember_name, 87 | root_module_name=root_module_name, 88 | parent=parent, 89 | ) 90 | 91 | def _handle_module(self, module, root_module_name) -> Iterator[CodeElement]: 92 | module_name = module.__name__ 93 | # Only traverse under hierarchy 94 | if not module_name.startswith(root_module_name): 95 | return None 96 | 97 | filename = get_filename(module) 98 | # Create Module object 99 | yield Module( 100 | name=module_name, 101 | docstring=get_docstring(module), 102 | filename=filename, 103 | ) 104 | # Go through members 105 | yield from self._process_submembers( 106 | root_module_name=root_module_name, parent=module 107 | ) 108 | 109 | def _handle_class_on_module( 110 | self, member, parent, root_module_name 111 | ) -> Iterator[CodeElement]: 112 | if inspect.getsourcefile(member) != inspect.getsourcefile(parent): 113 | return None 114 | 115 | if issubclass(member, Exception | Warning): 116 | return None 117 | 118 | yield Klass( 119 | name=member.__name__, 120 | module=member.__module__, 121 | docstring=get_docstring(member), 122 | line_number=get_line_number(member), 123 | path=_full_path(member), 124 | bases=[_full_path(k) for k in member.__bases__], 125 | best_import_path=_get_best_import_path_for_class(member), 126 | ) 127 | # Go through members 128 | yield from self._process_submembers( 129 | root_module_name=root_module_name, parent=member 130 | ) 131 | 132 | def _handle_function_or_method( 133 | self, member, member_name, parent 134 | ) -> Iterator[Method]: 135 | # Decoration 136 | while getattr(member, "__wrapped__", None): 137 | member = member.__wrapped__ 138 | 139 | # Checks 140 | if not ok_to_add_method(member, parent): 141 | return 142 | 143 | code, arguments, start_line = get_code(member) 144 | 145 | yield Method( 146 | name=member_name, 147 | docstring=get_docstring(member), 148 | code=code, 149 | kwargs=arguments[1:-1], 150 | line_number=start_line, 151 | klass_path=_full_path(parent), 152 | ) 153 | 154 | def _handle_class_attribute( 155 | self, member, member_name, parent 156 | ) -> Iterator[KlassAttribute]: 157 | # Replace lazy function call with an object representing it 158 | if isinstance(member, Promise): 159 | member = LazyAttribute(member) 160 | 161 | if not ok_to_add_attribute(member, member_name, parent): 162 | return 163 | 164 | yield KlassAttribute( 165 | name=member_name, 166 | value=get_value(member), 167 | line_number=get_line_number(member), 168 | klass_path=_full_path(parent), 169 | ) 170 | 171 | 172 | def _get_best_import_path_for_class(klass: type) -> str: 173 | module_path = best_path = klass.__module__ 174 | 175 | while module_path := module_path.rpartition(".")[0]: 176 | module = importlib.import_module(module_path) 177 | if getattr(module, klass.__name__, None) == klass: 178 | best_path = module_path 179 | return best_path 180 | 181 | 182 | def _full_path(klass: type) -> str: 183 | return f"{klass.__module__}.{klass.__name__}" 184 | 185 | 186 | def get_code(member): 187 | # Strip unneeded whitespace from beginning of code lines 188 | lines, start_line = inspect.getsourcelines(member) 189 | 190 | # Join code lines into one string 191 | code = "".join(lines) 192 | code = textwrap.dedent(code) 193 | 194 | # Get the method arguments 195 | arguments = inspect.signature(member).format() 196 | 197 | return code, arguments, start_line 198 | 199 | 200 | def get_docstring(member) -> str: 201 | return inspect.getdoc(member) or "" 202 | 203 | 204 | def get_filename(member) -> str: 205 | # Get full file name 206 | filename = inspect.getfile(member) 207 | 208 | # Find the system path it's in 209 | sys_folder = max((p for p in sys.path if p in filename), key=len) 210 | 211 | # Get the part of the file name after the folder on the system path. 212 | filename = filename[len(sys_folder) :] 213 | 214 | # Replace `.pyc` file extensions with `.py` 215 | if filename[-4:] == ".pyc": 216 | filename = filename[:-1] 217 | return filename 218 | 219 | 220 | def get_line_number(member) -> int: 221 | try: 222 | return inspect.getsourcelines(member)[1] 223 | except TypeError: 224 | return -1 225 | 226 | 227 | def get_value(member) -> str: 228 | return f"'{member}'" if isinstance(member, str) else str(member) 229 | 230 | 231 | def ok_to_add_attribute(member, member_name, parent) -> bool: 232 | if inspect.isclass(parent) and member in object.__dict__.values(): 233 | return False 234 | 235 | if member_name in BANNED_ATTR_NAMES: 236 | return False 237 | return True 238 | 239 | 240 | def ok_to_add_method(member, parent) -> bool: 241 | if inspect.getsourcefile(member) != inspect.getsourcefile(parent): 242 | return False 243 | 244 | if not inspect.isclass(parent): 245 | return False 246 | 247 | # Use line inspection to work out whether the method is defined on this 248 | # klass. Possibly not the best way, but I can't think of another atm. 249 | lines, start_line = inspect.getsourcelines(member) 250 | parent_lines, parent_start_line = inspect.getsourcelines(parent) 251 | if start_line < parent_start_line or start_line > parent_start_line + len( 252 | parent_lines 253 | ): 254 | return False 255 | return True 256 | 257 | 258 | class LazyAttribute: 259 | functions = { 260 | "gettext": "gettext_lazy", 261 | "reverse": "reverse_lazy", 262 | "ugettext": "ugettext_lazy", 263 | } 264 | 265 | def __init__(self, promise: Promise) -> None: 266 | func, self.args, self.kwargs, _ = promise.__reduce__()[1] 267 | try: 268 | self.lazy_func = self.functions[func.__name__] 269 | except KeyError: 270 | msg = f"'{func.__name__}' not in known lazily called functions" 271 | raise ImproperlyConfigured(msg) 272 | 273 | def __repr__(self) -> str: 274 | arguments = [] 275 | for arg in self.args: 276 | if isinstance(arg, str): 277 | arguments.append(f"'{arg}'") 278 | else: 279 | arguments.append(arg) 280 | for key, value in self.kwargs: 281 | if isinstance(key, str): 282 | key = f"'{key}'" 283 | if isinstance(value, str): 284 | value = f"'{value}'" 285 | arguments.append(f"{key}: {value}") 286 | func = self.lazy_func 287 | argument_string = ", ".join(arguments) 288 | return f"{func}({argument_string})" 289 | -------------------------------------------------------------------------------- /cbv/views.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import attrs 4 | from django import http 5 | from django.urls import reverse 6 | from django.views.generic import RedirectView, TemplateView, View 7 | 8 | from cbv.models import Klass, Module, ProjectVersion 9 | from cbv.queries import NavBuilder 10 | 11 | 12 | class RedirectToLatestVersionView(RedirectView): 13 | permanent = False 14 | 15 | def get_redirect_url(self, *, url_name: str, **kwargs): 16 | kwargs["version"] = ProjectVersion.objects.get_latest().version_number 17 | self.url = reverse(url_name, kwargs=kwargs) 18 | return super().get_redirect_url(**kwargs) 19 | 20 | 21 | class KlassDetailView(TemplateView): 22 | template_name = "cbv/klass_detail.html" 23 | 24 | @attrs.frozen 25 | class Ancestor: 26 | name: str 27 | url: str 28 | is_direct: bool 29 | 30 | @attrs.frozen 31 | class Child: 32 | name: str 33 | url: str 34 | 35 | def get_context_data(self, **kwargs): 36 | qs = Klass.objects.filter( 37 | name__iexact=self.kwargs["klass"], 38 | module__name__iexact=self.kwargs["module"], 39 | module__project_version__version_number=self.kwargs["version"], 40 | ).select_related("module__project_version") 41 | try: 42 | klass = qs.get() 43 | except Klass.DoesNotExist: 44 | raise http.Http404 45 | 46 | canonical_url_path = klass.get_latest_version_url() 47 | best_current_path = klass.get_absolute_url() 48 | if best_current_path != self.request.path: 49 | push_state_url = best_current_path 50 | else: 51 | push_state_url = None 52 | nav_builder = NavBuilder() 53 | version_switcher = nav_builder.make_version_switcher( 54 | klass.module.project_version, klass 55 | ) 56 | nav = nav_builder.get_nav_data( 57 | klass.module.project_version, klass.module, klass 58 | ) 59 | direct_ancestors = list(klass.get_ancestors()) 60 | ancestors = [ 61 | self.Ancestor( 62 | name=ancestor.name, 63 | url=ancestor.get_absolute_url(), 64 | is_direct=ancestor in direct_ancestors, 65 | ) 66 | for ancestor in klass.get_all_ancestors() 67 | ] 68 | children = [ 69 | self.Child( 70 | name=child.name, 71 | url=child.get_absolute_url(), 72 | ) 73 | for child in klass.get_all_children() 74 | ] 75 | return { 76 | "all_ancestors": ancestors, 77 | "all_children": children, 78 | "attributes": klass.get_prepared_attributes(), 79 | "canonical_url": self.request.build_absolute_uri(canonical_url_path), 80 | "klass": klass, 81 | "methods": list(klass.get_methods()), 82 | "nav": nav, 83 | "project": f"Django {klass.module.project_version.version_number}", 84 | "push_state_url": push_state_url, 85 | "version_switcher": version_switcher, 86 | "yuml_url": klass.basic_yuml_url(), 87 | } 88 | 89 | 90 | class LatestKlassRedirectView(RedirectView): 91 | def get_redirect_url(self, **kwargs): 92 | try: 93 | klass = Klass.objects.get_latest_for_name(klass_name=self.kwargs["klass"]) 94 | except Klass.DoesNotExist: 95 | raise http.Http404 96 | 97 | return klass.get_latest_version_url() 98 | 99 | 100 | @attrs.frozen 101 | class KlassData: 102 | name: str 103 | url: str 104 | 105 | 106 | class ModuleDetailView(TemplateView): 107 | template_name = "cbv/module_detail.html" 108 | push_state_url = None 109 | 110 | def get_object(self, queryset=None): 111 | try: 112 | obj = self.get_precise_object() 113 | except Module.DoesNotExist: 114 | try: 115 | obj = self.get_fuzzy_object() 116 | except Module.DoesNotExist: 117 | raise http.Http404 118 | self.push_state_url = obj.get_absolute_url() 119 | 120 | return obj 121 | 122 | def get(self, request, *args, **kwargs): 123 | try: 124 | self.project_version = ProjectVersion.objects.filter( 125 | version_number=kwargs["version"] 126 | ).get() 127 | except ProjectVersion.DoesNotExist: 128 | raise http.Http404 129 | return super().get(request, *args, **kwargs) 130 | 131 | def get_precise_object(self, queryset=None): 132 | return Module.objects.get( 133 | name=self.kwargs["module"], project_version=self.project_version 134 | ) 135 | 136 | def get_fuzzy_object(self, queryset=None): 137 | return Module.objects.get( 138 | name__iexact=self.kwargs["module"], 139 | project_version__version_number=self.kwargs["version"], 140 | ) 141 | 142 | def get_context_data(self, **kwargs): 143 | module = self.get_object() 144 | klasses = Klass.objects.filter(module=module).select_related( 145 | "module__project_version" 146 | ) 147 | klass_list = [KlassData(name=k.name, url=k.get_absolute_url()) for k in klasses] 148 | 149 | latest_version = ( 150 | Module.objects.filter( 151 | name=module.name, 152 | ) 153 | .select_related("project_version") 154 | .order_by("-project_version__sortable_version_number") 155 | .first() 156 | ) 157 | canonical_url_path = latest_version.get_absolute_url() 158 | nav_builder = NavBuilder() 159 | version_switcher = nav_builder.make_version_switcher(self.project_version) 160 | nav = nav_builder.get_nav_data(self.project_version, module) 161 | return { 162 | "canonical_url": self.request.build_absolute_uri(canonical_url_path), 163 | "klass_list": klass_list, 164 | "module_name": module.name, 165 | "nav": nav, 166 | "project": f"Django {self.project_version.version_number}", 167 | "push_state_url": self.push_state_url, 168 | "version_switcher": version_switcher, 169 | } 170 | 171 | 172 | @attrs.frozen 173 | class DjangoClassListItem: 174 | docstring: str 175 | is_secondary: bool 176 | name: str 177 | module_long_name: str 178 | module_name: str 179 | module_short_name: str 180 | url: str 181 | 182 | 183 | class VersionDetailView(TemplateView): 184 | template_name = "cbv/version_detail.html" 185 | 186 | def get_context_data(self, **kwargs): 187 | qs = ProjectVersion.objects.filter(version_number=kwargs["version"]) 188 | try: 189 | project_version = qs.get() 190 | except ProjectVersion.DoesNotExist: 191 | raise http.Http404 192 | 193 | nav_builder = NavBuilder() 194 | version_switcher = nav_builder.make_version_switcher(project_version) 195 | nav = nav_builder.get_nav_data(project_version) 196 | return { 197 | "nav": nav, 198 | "object_list": [ 199 | DjangoClassListItem( 200 | docstring=class_.docstring, 201 | is_secondary=class_.is_secondary(), 202 | name=class_.name, 203 | module_long_name=class_.module.long_name, 204 | module_name=class_.module.name, 205 | module_short_name=class_.module.short_name, 206 | url=class_.get_absolute_url(), 207 | ) 208 | for class_ in Klass.objects.filter( 209 | module__project_version=project_version 210 | ).select_related("module__project_version") 211 | ], 212 | "project": f"Django {project_version.version_number}", 213 | "version_switcher": version_switcher, 214 | } 215 | 216 | 217 | class HomeView(TemplateView): 218 | template_name = "home.html" 219 | 220 | def get_context_data(self, **kwargs): 221 | project_version = ProjectVersion.objects.get_latest() 222 | nav_builder = NavBuilder() 223 | version_switcher = nav_builder.make_version_switcher(project_version) 224 | nav = nav_builder.get_nav_data(project_version) 225 | return { 226 | "nav": nav, 227 | "object_list": [ 228 | DjangoClassListItem( 229 | docstring=class_.docstring, 230 | is_secondary=class_.is_secondary(), 231 | name=class_.name, 232 | module_long_name=class_.module.long_name, 233 | module_name=class_.module.name, 234 | module_short_name=class_.module.short_name, 235 | url=class_.get_absolute_url(), 236 | ) 237 | for class_ in Klass.objects.filter( 238 | module__project_version=project_version 239 | ).select_related("module__project_version") 240 | ], 241 | "project": f"Django {project_version.version_number}", 242 | "version_switcher": version_switcher, 243 | } 244 | 245 | 246 | class Sitemap(TemplateView): 247 | content_type = "application/xml" 248 | template_name = "sitemap.xml" 249 | 250 | def get_context_data(self, **kwargs: Any) -> dict[str, Any]: 251 | latest_version = ProjectVersion.objects.get_latest() 252 | klasses = Klass.objects.select_related("module__project_version").order_by( 253 | "-module__project_version__sortable_version_number", 254 | "module__name", 255 | "name", 256 | ) 257 | 258 | urls = [{"location": reverse("home"), "priority": 1.0}] 259 | for klass in klasses: 260 | priority = 0.9 if klass.module.project_version == latest_version else 0.5 261 | urls.append({"location": klass.get_absolute_url(), "priority": priority}) 262 | return {"urlset": urls} 263 | 264 | 265 | class BasicHealthcheck(View): 266 | """ 267 | Minimal "up" healthcheck endpoint. Returns an empty 200 response. 268 | 269 | Deliberately doesn't check the state of required services such as the database 270 | so that a misconfigured or down DB doesn't prevent a deploy. 271 | """ 272 | 273 | def get(self, request: http.HttpRequest) -> http.HttpResponse: 274 | return http.HttpResponse() 275 | -------------------------------------------------------------------------------- /cbv/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.urls import reverse 4 | 5 | 6 | class ProjectVersionManager(models.Manager): 7 | def get_by_natural_key(self, name: str, version_number: str) -> "ProjectVersion": 8 | return self.get( 9 | version_number=version_number, 10 | ) 11 | 12 | def get_latest(self) -> "ProjectVersion": 13 | return self.order_by("-sortable_version_number")[0] 14 | 15 | 16 | class ProjectVersion(models.Model): 17 | """Represents a particular version of a project in a python project hierarchy""" 18 | 19 | version_number = models.CharField(max_length=200) 20 | sortable_version_number = models.CharField(max_length=200) 21 | 22 | objects = ProjectVersionManager() 23 | 24 | class Meta: 25 | constraints = ( 26 | models.UniqueConstraint( 27 | fields=("version_number",), name="unique_number_per_version" 28 | ), 29 | ) 30 | ordering = ("-sortable_version_number",) 31 | 32 | def save(self, *args: object, **kwargs: object) -> None: 33 | if not self.sortable_version_number: 34 | self.sortable_version_number = self.generate_sortable_version_number() 35 | super().save(*args, **kwargs) 36 | 37 | def natural_key(self) -> tuple[str, str]: 38 | return ("Django", self.version_number) 39 | 40 | def get_absolute_url(self) -> str: 41 | return reverse( 42 | "version-detail", 43 | kwargs={ 44 | "version": self.version_number, 45 | }, 46 | ) 47 | 48 | def generate_sortable_version_number(self) -> str: 49 | return "".join(part.zfill(2) for part in self.version_number.split(".")) 50 | 51 | 52 | class ModuleManager(models.Manager): 53 | def get_by_natural_key( 54 | self, module_name: str, project_name: str, version_number: str 55 | ) -> "Module": 56 | return self.get( 57 | name=module_name, 58 | project_version=ProjectVersion.objects.get_by_natural_key( 59 | name=project_name, version_number=version_number 60 | ), 61 | ) 62 | 63 | 64 | class Module(models.Model): 65 | """Represents a module of a python project""" 66 | 67 | project_version = models.ForeignKey(ProjectVersion, models.CASCADE) 68 | name = models.CharField(max_length=200) 69 | docstring = models.TextField(default="") 70 | filename = models.CharField(max_length=511, default="") 71 | 72 | objects = ModuleManager() 73 | 74 | class Meta: 75 | unique_together = ("project_version", "name") 76 | 77 | def short_name(self) -> str: 78 | return self.name.split(".")[-1] 79 | 80 | def long_name(self) -> str: 81 | short_name = self.short_name() 82 | source_name = self.source_name() 83 | if short_name.lower() == source_name.lower(): 84 | return short_name 85 | return f"{source_name} {short_name}" 86 | 87 | def source_name(self) -> str: 88 | name = self.name 89 | while name: 90 | try: 91 | return settings.CBV_SOURCES[name] 92 | except KeyError: 93 | name = ".".join(name.split(".")[:-1]) 94 | 95 | def natural_key(self) -> tuple[str, str, str]: 96 | return (self.name,) + self.project_version.natural_key() 97 | 98 | natural_key.dependencies = ["cbv.ProjectVersion"] 99 | 100 | def get_absolute_url(self) -> str: 101 | return reverse( 102 | "module-detail", 103 | kwargs={ 104 | "version": self.project_version.version_number, 105 | "module": self.name, 106 | }, 107 | ) 108 | 109 | 110 | class KlassManager(models.Manager): 111 | def get_by_natural_key( 112 | self, klass_name: str, module_name: str, project_name: str, version_number: str 113 | ) -> "Klass": 114 | return self.get( 115 | name=klass_name, 116 | module=Module.objects.get_by_natural_key( 117 | module_name=module_name, 118 | project_name=project_name, 119 | version_number=version_number, 120 | ), 121 | ) 122 | 123 | def get_latest_for_name(self, klass_name: str) -> "Klass": 124 | qs = self.filter( 125 | name__iexact=klass_name, 126 | ) 127 | try: 128 | obj = qs.order_by( 129 | "-module__project_version__sortable_version_number", 130 | )[0] 131 | except IndexError: 132 | raise self.model.DoesNotExist 133 | else: 134 | return obj 135 | 136 | 137 | # TODO: quite a few of the methods on here should probably be denormed. 138 | class Klass(models.Model): 139 | """Represents a class in a module of a python project hierarchy""" 140 | 141 | module = models.ForeignKey(Module, models.CASCADE) 142 | name = models.CharField(max_length=200) 143 | docstring = models.TextField(default="") 144 | line_number = models.IntegerField() 145 | import_path = models.CharField(max_length=255) 146 | # because docs urls differ between Django versions 147 | docs_url = models.URLField(max_length=255, default="") 148 | 149 | objects = KlassManager() 150 | 151 | class Meta: 152 | unique_together = ("module", "name") 153 | ordering = ("module__name", "name") 154 | 155 | def natural_key(self) -> tuple[str, str, str, str]: 156 | return (self.name,) + self.module.natural_key() 157 | 158 | natural_key.dependencies = ["cbv.Module"] 159 | 160 | def is_secondary(self) -> bool: 161 | return ( 162 | self.name.startswith("Base") 163 | or self.name.endswith("Base") 164 | or self.name.endswith("Mixin") 165 | or self.name.endswith("Error") 166 | or self.name == "ProcessFormView" 167 | ) 168 | 169 | def get_absolute_url(self) -> str: 170 | return reverse( 171 | "klass-detail", 172 | kwargs={ 173 | "version": self.module.project_version.version_number, 174 | "module": self.module.name, 175 | "klass": self.name, 176 | }, 177 | ) 178 | 179 | def get_latest_version_url(self) -> str: 180 | latest = ( 181 | self._meta.model.objects.filter( 182 | module__name=self.module.name, 183 | name=self.name, 184 | ) 185 | .select_related("module__project_version") 186 | .order_by("-module__project_version__sortable_version_number") 187 | .first() 188 | ) 189 | return latest.get_absolute_url() 190 | 191 | def get_source_url(self) -> str: 192 | url = "https://github.com/django/django/blob/" 193 | version = self.module.project_version.version_number 194 | path = self.module.filename 195 | line = self.line_number 196 | return f"{url}{version}{path}#L{line}" 197 | 198 | def get_ancestors(self) -> models.QuerySet["Klass"]: 199 | if not hasattr(self, "_ancestors"): 200 | self._ancestors = Klass.objects.filter(inheritance__child=self).order_by( 201 | "inheritance__order" 202 | ) 203 | return self._ancestors 204 | 205 | def get_children(self) -> models.QuerySet["Klass"]: 206 | if not hasattr(self, "_descendants"): 207 | self._descendants = Klass.objects.filter( 208 | ancestor_relationships__parent=self 209 | ).order_by("name") 210 | return self._descendants 211 | 212 | # TODO: This is all mucho inefficient. Perhaps we should use mptt for 213 | # get_all_ancestors, get_all_children, get_methods, & get_attributes? 214 | def get_all_ancestors(self) -> list["Klass"]: 215 | if not hasattr(self, "_all_ancestors"): 216 | # Get immediate ancestors. 217 | ancestors = self.get_ancestors().select_related("module__project_version") 218 | 219 | # Flatten ancestors and their forebears into a list. 220 | tree = [] 221 | for ancestor in ancestors: 222 | tree.append(ancestor) 223 | tree += ancestor.get_all_ancestors() 224 | 225 | # Remove duplicates, leaving the last occurence in tact. 226 | # This is how python's MRO works. 227 | cleaned_ancestors: list[Klass] = [] 228 | for ancestor in reversed(tree): 229 | if ancestor not in cleaned_ancestors: 230 | cleaned_ancestors.insert(0, ancestor) 231 | 232 | # Cache the result on this object. 233 | self._all_ancestors = cleaned_ancestors 234 | return self._all_ancestors 235 | 236 | def get_all_children(self) -> models.QuerySet["Klass"]: 237 | if not hasattr(self, "_all_descendants"): 238 | children = self.get_children().select_related("module__project_version") 239 | for child in children: 240 | children = children | child.get_all_children() 241 | self._all_descendants = children 242 | return self._all_descendants 243 | 244 | def get_methods(self) -> models.QuerySet["Method"]: 245 | if not hasattr(self, "_methods"): 246 | methods = self.method_set.all().select_related("klass") 247 | for ancestor in self.get_all_ancestors(): 248 | methods = methods | ancestor.get_methods() 249 | self._methods = methods 250 | return self._methods 251 | 252 | def get_attributes(self) -> models.QuerySet["KlassAttribute"]: 253 | if not hasattr(self, "_attributes"): 254 | attrs = self.attribute_set.all() 255 | for ancestor in self.get_all_ancestors(): 256 | attrs = attrs | ancestor.get_attributes() 257 | self._attributes = attrs 258 | return self._attributes 259 | 260 | def get_prepared_attributes(self) -> models.QuerySet["KlassAttribute"]: 261 | attributes = self.get_attributes() 262 | # Make a dictionary of attributes based on name 263 | attribute_names: dict[str, list[KlassAttribute]] = {} 264 | for attr in attributes: 265 | try: 266 | attribute_names[attr.name] += [attr] 267 | except KeyError: 268 | attribute_names[attr.name] = [attr] 269 | 270 | ancestors = self.get_all_ancestors() 271 | 272 | # Find overridden attributes 273 | for name, attrs in attribute_names.items(): 274 | # Skip if we have only one attribute. 275 | if len(attrs) == 1: 276 | continue 277 | 278 | # Sort the attributes by ancestors. 279 | def _key(a: KlassAttribute) -> int: 280 | try: 281 | # If ancestor, return the index (>= 0) 282 | return ancestors.index(a.klass) 283 | except ValueError: # Raised by .index if item is not in list. 284 | # else a.klass == self, so return -1 285 | return -1 286 | 287 | sorted_attrs = sorted(attrs, key=_key) 288 | 289 | # Mark overriden KlassAttributes 290 | for a in sorted_attrs[1:]: 291 | a.overridden = True 292 | return attributes 293 | 294 | def basic_yuml_data(self, first: bool = False) -> list[str]: 295 | self._basic_yuml_data: list[str] 296 | if hasattr(self, "_basic_yuml_data"): 297 | return self._basic_yuml_data 298 | yuml_data = [] 299 | template = "[{parent}{{bg:{parent_col}}}]^-[{child}{{bg:{child_col}}}]" 300 | for ancestor in self.get_ancestors(): 301 | yuml_data.append( 302 | template.format( 303 | parent=ancestor.name, 304 | child=self.name, 305 | parent_col="white" if ancestor.is_secondary() else "lightblue", 306 | child_col=( 307 | "green" 308 | if first 309 | else "white" if self.is_secondary() else "lightblue" 310 | ), 311 | ) 312 | ) 313 | yuml_data += ancestor.basic_yuml_data() 314 | self._basic_yuml_data = yuml_data 315 | return self._basic_yuml_data 316 | 317 | def basic_yuml_url(self) -> str | None: 318 | data = ", ".join(self.basic_yuml_data(first=True)) 319 | if not data: 320 | return None 321 | return f"https://yuml.me/diagram/plain;/class/{data}.svg" 322 | 323 | 324 | class Inheritance(models.Model): 325 | """Represents the inheritance relationships for a Klass""" 326 | 327 | parent = models.ForeignKey(Klass, models.CASCADE) 328 | child = models.ForeignKey( 329 | Klass, models.CASCADE, related_name="ancestor_relationships" 330 | ) 331 | order = models.IntegerField() 332 | 333 | class Meta: 334 | ordering = ("order",) 335 | unique_together = ("child", "order") 336 | 337 | 338 | class KlassAttribute(models.Model): 339 | """Represents an attribute on a Klass""" 340 | 341 | klass = models.ForeignKey(Klass, models.CASCADE, related_name="attribute_set") 342 | name = models.CharField(max_length=200) 343 | value = models.CharField(max_length=511) 344 | line_number = models.IntegerField() 345 | 346 | class Meta: 347 | ordering = ("name",) 348 | unique_together = ("klass", "name") 349 | 350 | 351 | class Method(models.Model): 352 | """Represents a method on a Klass""" 353 | 354 | klass = models.ForeignKey(Klass, models.CASCADE) 355 | name = models.CharField(max_length=200) 356 | docstring = models.TextField(default="") 357 | code = models.TextField() 358 | kwargs = models.CharField(max_length=200) 359 | line_number = models.IntegerField() 360 | 361 | class Meta: 362 | ordering = ("name",) 363 | -------------------------------------------------------------------------------- /cbv/static/modernizr-2.5.3.min.js: -------------------------------------------------------------------------------- 1 | /* Modernizr 2.5.3 (Custom Build) | MIT & BSD 2 | * Build: http://www.modernizr.com/download/#-fontface-backgroundsize-borderimage-borderradius-boxshadow-flexbox-hsla-multiplebgs-opacity-rgba-textshadow-cssanimations-csscolumns-generatedcontent-cssgradients-cssreflections-csstransforms-csstransforms3d-csstransitions-applicationcache-canvas-canvastext-draganddrop-hashchange-history-audio-video-indexeddb-input-inputtypes-localstorage-postmessage-sessionstorage-websockets-websqldatabase-webworkers-geolocation-inlinesvg-smil-svg-svgclippaths-touch-webgl-shiv-cssclasses-teststyles-testprop-testallprops-hasevent-prefixes-domprefixes-load 3 | */ 4 | ;window.Modernizr=function(a,b,c){function C(a){j.cssText=a}function D(a,b){return C(n.join(a+";")+(b||""))}function E(a,b){return typeof a===b}function F(a,b){return!!~(""+a).indexOf(b)}function G(a,b){for(var d in a)if(j[a[d]]!==c)return b=="pfx"?a[d]:!0;return!1}function H(a,b,d){for(var e in a){var f=b[a[e]];if(f!==c)return d===!1?a[e]:E(f,"function")?f.bind(d||b):f}return!1}function I(a,b,c){var d=a.charAt(0).toUpperCase()+a.substr(1),e=(a+" "+p.join(d+" ")+d).split(" ");return E(b,"string")||E(b,"undefined")?G(e,b):(e=(a+" "+q.join(d+" ")+d).split(" "),H(e,b,c))}function K(){e.input=function(c){for(var d=0,e=c.length;d",a,""].join(""),k.id=h,m.innerHTML+=f,m.appendChild(k),l||(m.style.background="",g.appendChild(m)),i=c(k,a),l?k.parentNode.removeChild(k):m.parentNode.removeChild(m),!!i},z=function(){function d(d,e){e=e||b.createElement(a[d]||"div"),d="on"+d;var f=d in e;return f||(e.setAttribute||(e=b.createElement("div")),e.setAttribute&&e.removeAttribute&&(e.setAttribute(d,""),f=E(e[d],"function"),E(e[d],"undefined")||(e[d]=c),e.removeAttribute(d))),e=null,f}var a={select:"input",change:"input",submit:"form",reset:"form",error:"img",load:"img",abort:"img"};return d}(),A={}.hasOwnProperty,B;!E(A,"undefined")&&!E(A.call,"undefined")?B=function(a,b){return A.call(a,b)}:B=function(a,b){return b in a&&E(a.constructor.prototype[b],"undefined")},Function.prototype.bind||(Function.prototype.bind=function(b){var c=this;if(typeof c!="function")throw new TypeError;var d=w.call(arguments,1),e=function(){if(this instanceof e){var a=function(){};a.prototype=c.prototype;var f=new a,g=c.apply(f,d.concat(w.call(arguments)));return Object(g)===g?g:f}return c.apply(b,d.concat(w.call(arguments)))};return e});var J=function(c,d){var f=c.join(""),g=d.length;y(f,function(c,d){var f=b.styleSheets[b.styleSheets.length-1],h=f?f.cssRules&&f.cssRules[0]?f.cssRules[0].cssText:f.cssText||"":"",i=c.childNodes,j={};while(g--)j[i[g].id]=i[g];e.touch="ontouchstart"in a||a.DocumentTouch&&b instanceof DocumentTouch||(j.touch&&j.touch.offsetTop)===9,e.csstransforms3d=(j.csstransforms3d&&j.csstransforms3d.offsetLeft)===9&&j.csstransforms3d.offsetHeight===3,e.generatedcontent=(j.generatedcontent&&j.generatedcontent.offsetHeight)>=1,e.fontface=/src/i.test(h)&&h.indexOf(d.split(" ")[0])===0},g,d)}(['@font-face {font-family:"font";src:url("https://")}',["@media (",n.join("touch-enabled),("),h,")","{#touch{top:9px;position:absolute}}"].join(""),["@media (",n.join("transform-3d),("),h,")","{#csstransforms3d{left:9px;position:absolute;height:3px;}}"].join(""),['#generatedcontent:after{content:"',l,'";visibility:hidden}'].join("")],["fontface","touch","csstransforms3d","generatedcontent"]);s.flexbox=function(){return I("flexOrder")},s.canvas=function(){var a=b.createElement("canvas");return!!a.getContext&&!!a.getContext("2d")},s.canvastext=function(){return!!e.canvas&&!!E(b.createElement("canvas").getContext("2d").fillText,"function")},s.webgl=function(){try{var d=b.createElement("canvas"),e;e=!(!a.WebGLRenderingContext||!d.getContext("experimental-webgl")&&!d.getContext("webgl")),d=c}catch(f){e=!1}return e},s.touch=function(){return e.touch},s.geolocation=function(){return!!navigator.geolocation},s.postmessage=function(){return!!a.postMessage},s.websqldatabase=function(){return!!a.openDatabase},s.indexedDB=function(){return!!I("indexedDB",a)},s.hashchange=function(){return z("hashchange",a)&&(b.documentMode===c||b.documentMode>7)},s.history=function(){return!!a.history&&!!history.pushState},s.draganddrop=function(){var a=b.createElement("div");return"draggable"in a||"ondragstart"in a&&"ondrop"in a},s.websockets=function(){for(var b=-1,c=p.length;++b",d.insertBefore(c.lastChild,d.firstChild)}function h(){var a=k.elements;return typeof a=="string"?a.split(" "):a}function i(a){var b={},c=a.createElement,e=a.createDocumentFragment,f=e();a.createElement=function(a){var e=(b[a]||(b[a]=c(a))).cloneNode();return k.shivMethods&&e.canHaveChildren&&!d.test(a)?f.appendChild(e):e},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+h().join().replace(/\w+/g,function(a){return b[a]=c(a),f.createElement(a),'c("'+a+'")'})+");return n}")(k,f)}function j(a){var b;return a.documentShived?a:(k.shivCSS&&!e&&(b=!!g(a,"article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block}audio{display:none}canvas,video{display:inline-block;*display:inline;*zoom:1}[hidden]{display:none}audio[controls]{display:inline-block;*display:inline;*zoom:1}mark{background:#FF0;color:#000}")),f||(b=!i(a)),b&&(a.documentShived=b),a)}var c=a.html5||{},d=/^<|^(?:button|form|map|select|textarea)$/i,e,f;(function(){var a=b.createElement("a");a.innerHTML="",e="hidden"in a,f=a.childNodes.length==1||function(){try{b.createElement("a")}catch(a){return!0}var c=b.createDocumentFragment();return typeof c.cloneNode=="undefined"||typeof c.createDocumentFragment=="undefined"||typeof c.createElement=="undefined"}()})();var k={elements:c.elements||"abbr article aside audio bdi canvas data datalist details figcaption figure footer header hgroup mark meter nav output progress section summary time video",shivCSS:c.shivCSS!==!1,shivMethods:c.shivMethods!==!1,type:"default",shivDocument:j};a.html5=k,j(b)}(this,b),e._version=d,e._prefixes=n,e._domPrefixes=q,e._cssomPrefixes=p,e.hasEvent=z,e.testProp=function(a){return G([a])},e.testAllProps=I,e.testStyles=y,g.className=g.className.replace(/(^|\s)no-js(\s|$)/,"$1$2")+(f?" js "+v.join(" "):""),e}(this,this.document),function(a,b,c){function d(a){return o.call(a)=="[object Function]"}function e(a){return typeof a=="string"}function f(){}function g(a){return!a||a=="loaded"||a=="complete"||a=="uninitialized"}function h(){var a=p.shift();q=1,a?a.t?m(function(){(a.t=="c"?B.injectCss:B.injectJs)(a.s,0,a.a,a.x,a.e,1)},0):(a(),h()):q=0}function i(a,c,d,e,f,i,j){function k(b){if(!o&&g(l.readyState)&&(u.r=o=1,!q&&h(),l.onload=l.onreadystatechange=null,b)){a!="img"&&m(function(){t.removeChild(l)},50);for(var d in y[c])y[c].hasOwnProperty(d)&&y[c][d].onload()}}var j=j||B.errorTimeout,l={},o=0,r=0,u={t:d,s:c,e:f,a:i,x:j};y[c]===1&&(r=1,y[c]=[],l=b.createElement(a)),a=="object"?l.data=c:(l.src=c,l.type=a),l.width=l.height="0",l.onerror=l.onload=l.onreadystatechange=function(){k.call(this,r)},p.splice(e,0,u),a!="img"&&(r||y[c]===2?(t.insertBefore(l,s?null:n),m(k,j)):y[c].push(l))}function j(a,b,c,d,f){return q=0,b=b||"j",e(a)?i(b=="c"?v:u,a,b,this.i++,c,d,f):(p.splice(this.i++,0,a),p.length==1&&h()),this}function k(){var a=B;return a.loader={load:j,i:0},a}var l=b.documentElement,m=a.setTimeout,n=b.getElementsByTagName("script")[0],o={}.toString,p=[],q=0,r="MozAppearance"in l.style,s=r&&!!b.createRange().compareNode,t=s?l:n.parentNode,l=a.opera&&o.call(a.opera)=="[object Opera]",l=!!b.attachEvent&&!l,u=r?"object":l?"script":"img",v=l?"script":u,w=Array.isArray||function(a){return o.call(a)=="[object Array]"},x=[],y={},z={timeout:function(a,b){return b.length&&(a.timeout=b[0]),a}},A,B;B=function(a){function b(a){var a=a.split("!"),b=x.length,c=a.pop(),d=a.length,c={url:c,origUrl:c,prefixes:a},e,f,g;for(f=0;f label { 103 | float: none; 104 | width: auto; 105 | padding-top: 0; 106 | text-align: left; 107 | } 108 | .form-horizontal .controls { 109 | margin-left: 0; 110 | } 111 | .form-horizontal .control-list { 112 | padding-top: 0; 113 | } 114 | .form-horizontal .form-actions { 115 | padding-right: 10px; 116 | padding-left: 10px; 117 | } 118 | .modal { 119 | position: absolute; 120 | top: 10px; 121 | right: 10px; 122 | left: 10px; 123 | width: auto; 124 | margin: 0; 125 | } 126 | .modal.fade.in { 127 | top: auto; 128 | } 129 | .modal-header .close { 130 | padding: 10px; 131 | margin: -10px; 132 | } 133 | .carousel-caption { 134 | position: static; 135 | } 136 | } 137 | 138 | @media (max-width: 767px) { 139 | body { 140 | padding-right: 20px; 141 | padding-left: 20px; 142 | } 143 | .navbar-fixed-top, 144 | .navbar-fixed-bottom { 145 | margin-right: -20px; 146 | margin-left: -20px; 147 | } 148 | .container-fluid { 149 | padding: 0; 150 | } 151 | .dl-horizontal dt { 152 | float: none; 153 | width: auto; 154 | clear: none; 155 | text-align: left; 156 | } 157 | .dl-horizontal dd { 158 | margin-left: 0; 159 | } 160 | .container { 161 | width: auto; 162 | } 163 | .row-fluid { 164 | width: 100%; 165 | } 166 | .row, 167 | .thumbnails { 168 | margin-left: 0; 169 | } 170 | [class*="span"], 171 | .row-fluid [class*="span"] { 172 | display: block; 173 | float: none; 174 | width: auto; 175 | margin-left: 0; 176 | } 177 | .input-large, 178 | .input-xlarge, 179 | .input-xxlarge, 180 | input[class*="span"], 181 | select[class*="span"], 182 | textarea[class*="span"], 183 | .uneditable-input { 184 | display: block; 185 | width: 100%; 186 | min-height: 28px; 187 | -webkit-box-sizing: border-box; 188 | -moz-box-sizing: border-box; 189 | -ms-box-sizing: border-box; 190 | box-sizing: border-box; 191 | } 192 | .input-prepend input, 193 | .input-append input, 194 | .input-prepend input[class*="span"], 195 | .input-append input[class*="span"] { 196 | display: inline-block; 197 | width: auto; 198 | } 199 | } 200 | 201 | @media (min-width: 768px) and (max-width: 979px) { 202 | .row { 203 | margin-left: -20px; 204 | *zoom: 1; 205 | } 206 | .row:before, 207 | .row:after { 208 | display: table; 209 | content: ""; 210 | } 211 | .row:after { 212 | clear: both; 213 | } 214 | [class*="span"] { 215 | float: left; 216 | margin-left: 20px; 217 | } 218 | .container, 219 | .navbar-fixed-top .container, 220 | .navbar-fixed-bottom .container { 221 | width: 724px; 222 | } 223 | .span12 { 224 | width: 724px; 225 | } 226 | .span11 { 227 | width: 662px; 228 | } 229 | .span10 { 230 | width: 600px; 231 | } 232 | .span9 { 233 | width: 538px; 234 | } 235 | .span8 { 236 | width: 476px; 237 | } 238 | .span7 { 239 | width: 414px; 240 | } 241 | .span6 { 242 | width: 352px; 243 | } 244 | .span5 { 245 | width: 290px; 246 | } 247 | .span4 { 248 | width: 228px; 249 | } 250 | .span3 { 251 | width: 166px; 252 | } 253 | .span2 { 254 | width: 104px; 255 | } 256 | .span1 { 257 | width: 42px; 258 | } 259 | .offset12 { 260 | margin-left: 764px; 261 | } 262 | .offset11 { 263 | margin-left: 702px; 264 | } 265 | .offset10 { 266 | margin-left: 640px; 267 | } 268 | .offset9 { 269 | margin-left: 578px; 270 | } 271 | .offset8 { 272 | margin-left: 516px; 273 | } 274 | .offset7 { 275 | margin-left: 454px; 276 | } 277 | .offset6 { 278 | margin-left: 392px; 279 | } 280 | .offset5 { 281 | margin-left: 330px; 282 | } 283 | .offset4 { 284 | margin-left: 268px; 285 | } 286 | .offset3 { 287 | margin-left: 206px; 288 | } 289 | .offset2 { 290 | margin-left: 144px; 291 | } 292 | .offset1 { 293 | margin-left: 82px; 294 | } 295 | .row-fluid { 296 | width: 100%; 297 | *zoom: 1; 298 | } 299 | .row-fluid:before, 300 | .row-fluid:after { 301 | display: table; 302 | content: ""; 303 | } 304 | .row-fluid:after { 305 | clear: both; 306 | } 307 | .row-fluid [class*="span"] { 308 | display: block; 309 | float: left; 310 | width: 100%; 311 | min-height: 28px; 312 | margin-left: 2.762430939%; 313 | *margin-left: 2.709239449638298%; 314 | -webkit-box-sizing: border-box; 315 | -moz-box-sizing: border-box; 316 | -ms-box-sizing: border-box; 317 | box-sizing: border-box; 318 | } 319 | .row-fluid [class*="span"]:first-child { 320 | margin-left: 0; 321 | } 322 | .row-fluid .span12 { 323 | width: 99.999999993%; 324 | *width: 99.9468085036383%; 325 | } 326 | .row-fluid .span11 { 327 | width: 91.436464082%; 328 | *width: 91.38327259263829%; 329 | } 330 | .row-fluid .span10 { 331 | width: 82.87292817100001%; 332 | *width: 82.8197366816383%; 333 | } 334 | .row-fluid .span9 { 335 | width: 74.30939226%; 336 | *width: 74.25620077063829%; 337 | } 338 | .row-fluid .span8 { 339 | width: 65.74585634900001%; 340 | *width: 65.6926648596383%; 341 | } 342 | .row-fluid .span7 { 343 | width: 57.182320438000005%; 344 | *width: 57.129128948638304%; 345 | } 346 | .row-fluid .span6 { 347 | width: 48.618784527%; 348 | *width: 48.5655930376383%; 349 | } 350 | .row-fluid .span5 { 351 | width: 40.055248616%; 352 | *width: 40.0020571266383%; 353 | } 354 | .row-fluid .span4 { 355 | width: 31.491712705%; 356 | *width: 31.4385212156383%; 357 | } 358 | .row-fluid .span3 { 359 | width: 22.928176794%; 360 | *width: 22.874985304638297%; 361 | } 362 | .row-fluid .span2 { 363 | width: 14.364640883%; 364 | *width: 14.311449393638298%; 365 | } 366 | .row-fluid .span1 { 367 | width: 5.801104972%; 368 | *width: 5.747913482638298%; 369 | } 370 | input, 371 | textarea, 372 | .uneditable-input { 373 | margin-left: 0; 374 | } 375 | input.span12, 376 | textarea.span12, 377 | .uneditable-input.span12 { 378 | width: 714px; 379 | } 380 | input.span11, 381 | textarea.span11, 382 | .uneditable-input.span11 { 383 | width: 652px; 384 | } 385 | input.span10, 386 | textarea.span10, 387 | .uneditable-input.span10 { 388 | width: 590px; 389 | } 390 | input.span9, 391 | textarea.span9, 392 | .uneditable-input.span9 { 393 | width: 528px; 394 | } 395 | input.span8, 396 | textarea.span8, 397 | .uneditable-input.span8 { 398 | width: 466px; 399 | } 400 | input.span7, 401 | textarea.span7, 402 | .uneditable-input.span7 { 403 | width: 404px; 404 | } 405 | input.span6, 406 | textarea.span6, 407 | .uneditable-input.span6 { 408 | width: 342px; 409 | } 410 | input.span5, 411 | textarea.span5, 412 | .uneditable-input.span5 { 413 | width: 280px; 414 | } 415 | input.span4, 416 | textarea.span4, 417 | .uneditable-input.span4 { 418 | width: 218px; 419 | } 420 | input.span3, 421 | textarea.span3, 422 | .uneditable-input.span3 { 423 | width: 156px; 424 | } 425 | input.span2, 426 | textarea.span2, 427 | .uneditable-input.span2 { 428 | width: 94px; 429 | } 430 | input.span1, 431 | textarea.span1, 432 | .uneditable-input.span1 { 433 | width: 32px; 434 | } 435 | } 436 | 437 | @media (min-width: 1200px) { 438 | .row { 439 | margin-left: -30px; 440 | *zoom: 1; 441 | } 442 | .row:before, 443 | .row:after { 444 | display: table; 445 | content: ""; 446 | } 447 | .row:after { 448 | clear: both; 449 | } 450 | [class*="span"] { 451 | float: left; 452 | margin-left: 30px; 453 | } 454 | .container, 455 | .navbar-fixed-top .container, 456 | .navbar-fixed-bottom .container { 457 | width: 1170px; 458 | } 459 | .span12 { 460 | width: 1170px; 461 | } 462 | .span11 { 463 | width: 1070px; 464 | } 465 | .span10 { 466 | width: 970px; 467 | } 468 | .span9 { 469 | width: 870px; 470 | } 471 | .span8 { 472 | width: 770px; 473 | } 474 | .span7 { 475 | width: 670px; 476 | } 477 | .span6 { 478 | width: 570px; 479 | } 480 | .span5 { 481 | width: 470px; 482 | } 483 | .span4 { 484 | width: 370px; 485 | } 486 | .span3 { 487 | width: 270px; 488 | } 489 | .span2 { 490 | width: 170px; 491 | } 492 | .span1 { 493 | width: 70px; 494 | } 495 | .offset12 { 496 | margin-left: 1230px; 497 | } 498 | .offset11 { 499 | margin-left: 1130px; 500 | } 501 | .offset10 { 502 | margin-left: 1030px; 503 | } 504 | .offset9 { 505 | margin-left: 930px; 506 | } 507 | .offset8 { 508 | margin-left: 830px; 509 | } 510 | .offset7 { 511 | margin-left: 730px; 512 | } 513 | .offset6 { 514 | margin-left: 630px; 515 | } 516 | .offset5 { 517 | margin-left: 530px; 518 | } 519 | .offset4 { 520 | margin-left: 430px; 521 | } 522 | .offset3 { 523 | margin-left: 330px; 524 | } 525 | .offset2 { 526 | margin-left: 230px; 527 | } 528 | .offset1 { 529 | margin-left: 130px; 530 | } 531 | .row-fluid { 532 | width: 100%; 533 | *zoom: 1; 534 | } 535 | .row-fluid:before, 536 | .row-fluid:after { 537 | display: table; 538 | content: ""; 539 | } 540 | .row-fluid:after { 541 | clear: both; 542 | } 543 | .row-fluid [class*="span"] { 544 | display: block; 545 | float: left; 546 | width: 100%; 547 | min-height: 28px; 548 | margin-left: 2.564102564%; 549 | *margin-left: 2.510911074638298%; 550 | -webkit-box-sizing: border-box; 551 | -moz-box-sizing: border-box; 552 | -ms-box-sizing: border-box; 553 | box-sizing: border-box; 554 | } 555 | .row-fluid [class*="span"]:first-child { 556 | margin-left: 0; 557 | } 558 | .row-fluid .span12 { 559 | width: 100%; 560 | *width: 99.94680851063829%; 561 | } 562 | .row-fluid .span11 { 563 | width: 91.45299145300001%; 564 | *width: 91.3997999636383%; 565 | } 566 | .row-fluid .span10 { 567 | width: 82.905982906%; 568 | *width: 82.8527914166383%; 569 | } 570 | .row-fluid .span9 { 571 | width: 74.358974359%; 572 | *width: 74.30578286963829%; 573 | } 574 | .row-fluid .span8 { 575 | width: 65.81196581200001%; 576 | *width: 65.7587743226383%; 577 | } 578 | .row-fluid .span7 { 579 | width: 57.264957265%; 580 | *width: 57.2117657756383%; 581 | } 582 | .row-fluid .span6 { 583 | width: 48.717948718%; 584 | *width: 48.6647572286383%; 585 | } 586 | .row-fluid .span5 { 587 | width: 40.170940171000005%; 588 | *width: 40.117748681638304%; 589 | } 590 | .row-fluid .span4 { 591 | width: 31.623931624%; 592 | *width: 31.5707401346383%; 593 | } 594 | .row-fluid .span3 { 595 | width: 23.076923077%; 596 | *width: 23.0237315876383%; 597 | } 598 | .row-fluid .span2 { 599 | width: 14.529914530000001%; 600 | *width: 14.4767230406383%; 601 | } 602 | .row-fluid .span1 { 603 | width: 5.982905983%; 604 | *width: 5.929714493638298%; 605 | } 606 | input, 607 | textarea, 608 | .uneditable-input { 609 | margin-left: 0; 610 | } 611 | input.span12, 612 | textarea.span12, 613 | .uneditable-input.span12 { 614 | width: 1160px; 615 | } 616 | input.span11, 617 | textarea.span11, 618 | .uneditable-input.span11 { 619 | width: 1060px; 620 | } 621 | input.span10, 622 | textarea.span10, 623 | .uneditable-input.span10 { 624 | width: 960px; 625 | } 626 | input.span9, 627 | textarea.span9, 628 | .uneditable-input.span9 { 629 | width: 860px; 630 | } 631 | input.span8, 632 | textarea.span8, 633 | .uneditable-input.span8 { 634 | width: 760px; 635 | } 636 | input.span7, 637 | textarea.span7, 638 | .uneditable-input.span7 { 639 | width: 660px; 640 | } 641 | input.span6, 642 | textarea.span6, 643 | .uneditable-input.span6 { 644 | width: 560px; 645 | } 646 | input.span5, 647 | textarea.span5, 648 | .uneditable-input.span5 { 649 | width: 460px; 650 | } 651 | input.span4, 652 | textarea.span4, 653 | .uneditable-input.span4 { 654 | width: 360px; 655 | } 656 | input.span3, 657 | textarea.span3, 658 | .uneditable-input.span3 { 659 | width: 260px; 660 | } 661 | input.span2, 662 | textarea.span2, 663 | .uneditable-input.span2 { 664 | width: 160px; 665 | } 666 | input.span1, 667 | textarea.span1, 668 | .uneditable-input.span1 { 669 | width: 60px; 670 | } 671 | .thumbnails { 672 | margin-left: -30px; 673 | } 674 | .thumbnails > li { 675 | margin-left: 30px; 676 | } 677 | .row-fluid .thumbnails { 678 | margin-left: 0; 679 | } 680 | } 681 | 682 | @media (max-width: 979px) { 683 | body { 684 | padding-top: 0; 685 | } 686 | .navbar-fixed-top { 687 | position: static; 688 | margin-bottom: 18px; 689 | } 690 | .navbar-fixed-top .navbar-inner { 691 | padding: 5px; 692 | } 693 | .navbar .container { 694 | width: auto; 695 | padding: 0; 696 | } 697 | .navbar .brand { 698 | padding-right: 10px; 699 | padding-left: 10px; 700 | margin: 0 0 0 -5px; 701 | } 702 | .nav-collapse { 703 | clear: both; 704 | } 705 | .nav-collapse .nav { 706 | float: none; 707 | margin: 0 0 9px; 708 | } 709 | .nav-collapse .nav > li { 710 | float: none; 711 | } 712 | .nav-collapse .nav > li > a { 713 | margin-bottom: 2px; 714 | } 715 | .nav-collapse .nav > .divider-vertical { 716 | display: none; 717 | } 718 | .nav-collapse .nav .nav-header { 719 | color: #999999; 720 | text-shadow: none; 721 | } 722 | .nav-collapse .nav > li > a, 723 | .nav-collapse .dropdown-menu a { 724 | padding: 6px 15px; 725 | font-weight: bold; 726 | color: #999999; 727 | -webkit-border-radius: 3px; 728 | -moz-border-radius: 3px; 729 | border-radius: 3px; 730 | } 731 | .nav-collapse .btn { 732 | padding: 4px 10px 4px; 733 | font-weight: normal; 734 | -webkit-border-radius: 4px; 735 | -moz-border-radius: 4px; 736 | border-radius: 4px; 737 | } 738 | .nav-collapse .dropdown-menu li + li a { 739 | margin-bottom: 2px; 740 | } 741 | .nav-collapse .nav > li > a:hover, 742 | .nav-collapse .dropdown-menu a:hover { 743 | background-color: #222222; 744 | } 745 | .nav-collapse.in .btn-group { 746 | padding: 0; 747 | margin-top: 5px; 748 | } 749 | .nav-collapse .dropdown-menu { 750 | position: static; 751 | top: auto; 752 | left: auto; 753 | display: block; 754 | float: none; 755 | max-width: none; 756 | padding: 0; 757 | margin: 0 15px; 758 | background-color: transparent; 759 | border: none; 760 | -webkit-border-radius: 0; 761 | -moz-border-radius: 0; 762 | border-radius: 0; 763 | -webkit-box-shadow: none; 764 | -moz-box-shadow: none; 765 | box-shadow: none; 766 | } 767 | .nav-collapse .dropdown-menu:before, 768 | .nav-collapse .dropdown-menu:after { 769 | display: none; 770 | } 771 | .nav-collapse .dropdown-menu .divider { 772 | display: none; 773 | } 774 | .nav-collapse .navbar-form, 775 | .nav-collapse .navbar-search { 776 | float: none; 777 | padding: 9px 15px; 778 | margin: 9px 0; 779 | border-top: 1px solid #222222; 780 | border-bottom: 1px solid #222222; 781 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1); 782 | -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1); 783 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1); 784 | } 785 | .navbar .nav-collapse .nav.pull-right { 786 | float: none; 787 | margin-left: 0; 788 | } 789 | .nav-collapse, 790 | .nav-collapse.collapse { 791 | height: 0; 792 | overflow: hidden; 793 | } 794 | .navbar .btn-navbar { 795 | display: block; 796 | } 797 | .navbar-static .navbar-inner { 798 | padding-right: 10px; 799 | padding-left: 10px; 800 | } 801 | } 802 | 803 | @media (min-width: 980px) { 804 | .nav-collapse.collapse { 805 | height: auto !important; 806 | overflow: visible !important; 807 | } 808 | } 809 | -------------------------------------------------------------------------------- /tests/_page_snapshots/module-detail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | django.views.generic.edit -- Classy CBV 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 382 |
    383 | 417 | 418 | 425 | 426 |
    427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | -------------------------------------------------------------------------------- /tests/_page_snapshots/fuzzy-module-detail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | django.views.generic.edit -- Classy CBV 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 33 | 386 |
    387 | 421 | 422 | 429 | 430 |
    431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | --------------------------------------------------------------------------------