From ef113376da2997c8c449bfe8179e53a14bbd8f33 Mon Sep 17 00:00:00 2001 From: Jacob Fredericksen Date: Mon, 24 May 2021 21:50:23 -0700 Subject: [PATCH] refactoring (#423) * refactoring --- .dockerignore | 5 +- .github/actions/setup/action.yml | 2 +- .github/workflows/delivery.yml | 2 +- .github/workflows/integration.yml | 8 +- .github/workflows/seed.yml | 2 +- .gitignore | 3 +- .pyre_configuration | 11 ++ .vscode/cspell.json | 3 + .vscode/settings.json | 1 - .watchmanconfig | 1 + apps/admin/__init__.py | 7 +- apps/admin/admin_menu.py | 7 +- apps/admin/inlines.py | 45 +----- apps/admin/model_admin.py | 38 +++-- apps/entities/admin/entities.py | 2 +- .../migrations/0004_auto_20210524_0113.py | 29 ++++ apps/entities/models/entity_image.py | 4 +- .../migrations/0003_auto_20210524_0113.py | 24 +++ apps/occurrences/admin/__init__.py | 1 - apps/occurrences/api/views.py | 4 +- apps/occurrences/constants.py | 18 --- apps/occurrences/managers.py | 51 ------ .../migrations/0003_auto_20210523_0012.py | 31 ++++ apps/occurrences/models/__init__.py | 4 - apps/occurrences/models/occurrence_chain.py | 49 ------ .../models/occurrence_relations.py | 13 -- apps/occurrences/serializers.py | 22 --- apps/places/models/model_with_locations.py | 44 ++++- apps/propositions/admin/__init__.py | 2 + .../admin/filters.py | 0 .../admin/occurrences.py | 14 +- .../{admin.py => admin/propositions.py} | 24 ++- apps/propositions/api/schema.py | 1 - apps/propositions/api/serializers.py | 18 +++ apps/propositions/migrations/0001_initial.py | 3 +- .../migrations/0011_auto_20210524_0113.py | 24 +++ .../migrations/0012_auto_20210525_0314.py | 80 +++++++++ .../migrations/0013_auto_20210525_0341.py | 29 ++++ apps/propositions/models/__init__.py | 9 +- .../models/occurrence.py | 34 ++-- apps/propositions/models/proposition.py | 64 ++++++-- apps/propositions/tests.py | 15 +- apps/quotes/admin/quotes.py | 6 +- apps/quotes/migrations/0001_initial.py | 3 +- .../migrations/0006_auto_20210524_0113.py | 24 +++ .../models/model_with_related_quotes.py | 5 +- apps/quotes/models/quote.py | 2 +- apps/search/admin.py | 51 +++++- apps/search/documents/occurrence.py | 2 +- apps/search/models/searchable_model.py | 2 +- apps/sources/admin/citations.py | 10 +- .../migrations/0005_webpage_website_name.py | 18 +++ apps/sources/models/model_with_sources.py | 2 +- apps/sources/models/sources/webpage.py | 13 +- apps/stories/migrations/0001_initial.py | 3 +- apps/topics/admin/__init__.py | 2 +- .../admin/{related_topics.py => tags.py} | 18 +-- config/hooks/pre-commit | 4 +- config/nginx/dev/nginx.conf | 4 +- config/nginx/prod/nginx.conf | 6 +- core/config/_debug_toolbar.py | 18 ++- core/config/_watchman.py | 6 +- core/config/admin.py | 10 ++ core/config/caches.py | 3 +- .../config/{debug_toolbar.py => debugging.py} | 7 +- core/config/tinymce.py | 2 + core/constants/content_types.py | 2 +- core/fields/custom_m2m_field.py | 9 +- core/settings.py | 50 +++--- core/structures/source_file.py | 4 +- core/templates/_navbar.html | 2 +- core/tests.py | 3 +- core/urls.py | 9 +- frontend/components/Navbar.jsx | 2 +- frontend/package-lock.json | 97 +++++++---- frontend/package.json | 8 +- poetry.lock | 153 +++++++++++++----- pyproject.toml | 6 +- robots.txt | 3 +- 79 files changed, 885 insertions(+), 432 deletions(-) create mode 100644 .pyre_configuration create mode 100644 .watchmanconfig create mode 100644 apps/entities/migrations/0004_auto_20210524_0113.py create mode 100644 apps/images/migrations/0003_auto_20210524_0113.py delete mode 100644 apps/occurrences/admin/__init__.py delete mode 100644 apps/occurrences/constants.py delete mode 100644 apps/occurrences/managers.py create mode 100644 apps/occurrences/migrations/0003_auto_20210523_0012.py delete mode 100644 apps/occurrences/models/__init__.py delete mode 100644 apps/occurrences/models/occurrence_chain.py delete mode 100644 apps/occurrences/models/occurrence_relations.py delete mode 100644 apps/occurrences/serializers.py create mode 100644 apps/propositions/admin/__init__.py rename apps/{occurrences => propositions}/admin/filters.py (100%) rename apps/{occurrences => propositions}/admin/occurrences.py (69%) rename apps/propositions/{admin.py => admin/propositions.py} (81%) create mode 100644 apps/propositions/migrations/0011_auto_20210524_0113.py create mode 100644 apps/propositions/migrations/0012_auto_20210525_0314.py create mode 100644 apps/propositions/migrations/0013_auto_20210525_0341.py rename apps/{occurrences => propositions}/models/occurrence.py (86%) create mode 100644 apps/quotes/migrations/0006_auto_20210524_0113.py create mode 100644 apps/sources/migrations/0005_webpage_website_name.py rename apps/topics/admin/{related_topics.py => tags.py} (66%) rename core/config/{debug_toolbar.py => debugging.py} (87%) diff --git a/.dockerignore b/.dockerignore index e0b097667..3c3691e5e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -44,12 +44,11 @@ backups/ .init/ # Testing / linting / coverage reports -.cache +.cache/ .coverage_html/ .hypothesis/ -.mypy_cache/ .nox/ -.pytest_cache/ +.pyre/ .selenium/ .tox/ htmlcov/ diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 55f6cb4eb..c2406aafd 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -41,7 +41,7 @@ runs: shell: bash # Install and configure Poetry. - name: Install and configure Poetry - uses: snok/install-poetry@v1.1.4 + uses: snok/install-poetry@v1.1.6 with: virtualenvs-create: true virtualenvs-in-project: true diff --git a/.github/workflows/delivery.yml b/.github/workflows/delivery.yml index f3e82f770..70a86fa9c 100644 --- a/.github/workflows/delivery.yml +++ b/.github/workflows/delivery.yml @@ -106,7 +106,7 @@ jobs: shell: bash # Install and configure Poetry. - name: Install and configure Poetry - uses: snok/install-poetry@v1.1.4 + uses: snok/install-poetry@v1.1.6 with: virtualenvs-create: true virtualenvs-in-project: true diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index edb3c3e42..3afedaf18 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -103,7 +103,7 @@ jobs: shell: bash # Install and configure Poetry. - name: Install and configure Poetry - uses: snok/install-poetry@v1.1.4 + uses: snok/install-poetry@v1.1.6 with: virtualenvs-create: true virtualenvs-in-project: true @@ -202,7 +202,7 @@ jobs: shell: bash # Install and configure Poetry. - name: Install and configure Poetry - uses: snok/install-poetry@v1.1.4 + uses: snok/install-poetry@v1.1.6 with: virtualenvs-create: true virtualenvs-in-project: true @@ -282,7 +282,7 @@ jobs: shell: bash # Install and configure Poetry. - name: Install and configure Poetry - uses: snok/install-poetry@v1.1.4 + uses: snok/install-poetry@v1.1.6 with: virtualenvs-create: true virtualenvs-in-project: true @@ -357,7 +357,7 @@ jobs: shell: bash # Install and configure Poetry. - name: Install and configure Poetry - uses: snok/install-poetry@v1.1.4 + uses: snok/install-poetry@v1.1.6 with: virtualenvs-create: true virtualenvs-in-project: true diff --git a/.github/workflows/seed.yml b/.github/workflows/seed.yml index b7182e6dc..1eaaf46fd 100644 --- a/.github/workflows/seed.yml +++ b/.github/workflows/seed.yml @@ -68,7 +68,7 @@ jobs: shell: bash # Install and configure Poetry. - name: Install and configure Poetry - uses: snok/install-poetry@v1.1.4 + uses: snok/install-poetry@v1.1.6 with: virtualenvs-create: true virtualenvs-in-project: true diff --git a/.gitignore b/.gitignore index 5b00076db..63b7b90ab 100644 --- a/.gitignore +++ b/.gitignore @@ -66,10 +66,9 @@ backups/ .cache .coverage_html/ .hypothesis/ -.mypy_cache/ .nox/ -.pytest_cache/ .pytype/ +.pyre/ .selenium/ .tox/ htmlcov/ diff --git a/.pyre_configuration b/.pyre_configuration new file mode 100644 index 000000000..435977d09 --- /dev/null +++ b/.pyre_configuration @@ -0,0 +1,11 @@ +{ + "source_directories": [ + "." + ], + "exclude": [ + ".*\/node_modules\/.*", + ".*\/.venv\/.*", + ".*\/.cache\/.*" + ], + "taint_models_path": ".venv/lib" +} diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 062aeb4fa..8e49b5b98 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -8,6 +8,7 @@ "Bennion", "braintree", "buildx", + "crontabschedule", "CSRF", "ctype", "dbbackup", @@ -26,6 +27,7 @@ "ghcr", "graphiql", "graphviz", + "grappelli", "iframe", "iglob", "Imgur", @@ -68,6 +70,7 @@ "stylelint", "testcafe", "tinymce", + "uncachable", "unstaged", "venv", "virtualenvs", diff --git a/.vscode/settings.json b/.vscode/settings.json index 17f5fcaa4..2785fa48c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -66,7 +66,6 @@ "python.linting.banditEnabled": false, "python.linting.banditPath": "${workspaceFolder}/.venv/bin/bandit", "python.linting.lintOnSave": false, - "python.linting.mypyEnabled": true, "python.linting.mypyPath": "${workspaceFolder}/.venv/bin/mypy", "python.linting.pycodestylePath": "${workspaceFolder}/.venv/bin/pycodestyle", "python.linting.pydocstylePath": "${workspaceFolder}/.venv/bin/pydocstyle", diff --git a/.watchmanconfig b/.watchmanconfig new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/apps/admin/__init__.py b/apps/admin/__init__.py index 9e5cb21c8..ce1a79829 100644 --- a/apps/admin/__init__.py +++ b/apps/admin/__init__.py @@ -1,9 +1,4 @@ """Admin app for ModularHistory.""" -from .inlines import ( - GenericStackedInline, - GenericTabularInline, - StackedInline, - TabularInline, -) +from .inlines import StackedInline, TabularInline from .model_admin import ModelAdmin, admin_site diff --git a/apps/admin/admin_menu.py b/apps/admin/admin_menu.py index f6cc32ebf..3e70730e7 100644 --- a/apps/admin/admin_menu.py +++ b/apps/admin/admin_menu.py @@ -9,6 +9,7 @@ from admin_tools.menu import Menu, items from django.apps import apps +from django.conf import settings from django.urls import reverse from django.utils.translation import ugettext_lazy as _ @@ -56,6 +57,7 @@ def __init__(self, **kwargs): title='Applications', exclude=[ 'allauth.*', + 'admin_honeypot.*', 'rest_framework.*', 'defender.*', 'django_celery_*', @@ -73,7 +75,10 @@ def _menu_items(self): for model_cls in models: model_name = model_cls.__name__ children.append( - items.MenuItem(model_name, f'/admin/{app}/{model_name.lower()}/') + items.MenuItem( + model_name, + f'/{settings.ADMIN_URL_PREFIX}/{app}/{model_name.lower()}/', + ) ) menu_items.append(items.MenuItem(app, children=children)) return menu_items diff --git a/apps/admin/inlines.py b/apps/admin/inlines.py index c605b15f4..a57726c2b 100644 --- a/apps/admin/inlines.py +++ b/apps/admin/inlines.py @@ -1,11 +1,8 @@ -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING -from nested_admin.nested import ( - NestedGenericStackedInline, - NestedGenericTabularInline, - NestedStackedInline, - NestedTabularInline, -) +# from django.contrib.admin import StackedInline as BaseStackedInline +# from django.contrib.admin import TabularInline as BaseTabularInline +from nested_admin.nested import NestedStackedInline, NestedTabularInline from apps.admin.model_admin import FORM_FIELD_OVERRIDES @@ -13,51 +10,19 @@ from core.models.model import Model -class GenericTabularInline(NestedGenericTabularInline): - """Tabular inline admin for generically related objects.""" - - formfield_overrides = FORM_FIELD_OVERRIDES - - -class GenericStackedInline(NestedGenericStackedInline): - """Stacked inline admin for generically related objects.""" - - formfield_overrides = FORM_FIELD_OVERRIDES - - class StackedInline(NestedStackedInline): """Inline admin with fields stacked vertically.""" formfield_overrides = FORM_FIELD_OVERRIDES - def get_fields(self, request, model_instance=None) -> List[str]: - """Return reordered fields to be displayed in the admin.""" - fields = super().get_fields(request, model_instance) - return reorder_fields(fields) - class TabularInline(NestedTabularInline): """Inline admin with fields laid out horizontally.""" formfield_overrides = FORM_FIELD_OVERRIDES - def get_extra(self, request, model_instance: Optional['Model'] = None, **kwargs): + def get_extra(self, request, *args, **kwargs): """Return the number of extra/blank rows to display.""" if len(self.get_queryset(request)): return 0 return 1 - - def get_fields(self, request, model_instance=None) -> List[str]: - """Return reordered fields to be displayed in the admin.""" - fields = super().get_fields(request, model_instance) - return reorder_fields(fields) - - -def reorder_fields(fields) -> List[str]: - """Return a reordered list of fields to display in the admin.""" - ordered_fields = ('page_number', 'end_page_number', 'notes', 'position') - for field_name in ordered_fields: - if field_name in fields: - fields.remove(field_name) - fields.append(field_name) - return fields diff --git a/apps/admin/model_admin.py b/apps/admin/model_admin.py index 639e91310..9570925c7 100644 --- a/apps/admin/model_admin.py +++ b/apps/admin/model_admin.py @@ -4,6 +4,7 @@ from aenum import Constant from django.conf import settings from django.contrib.admin import ListFilter +from django.contrib.admin import ModelAdmin as BaseModelAdmin from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector from django.contrib.sites.models import Site @@ -16,8 +17,11 @@ PeriodicTask, SolarSchedule, ) -from django_celery_results.admin import TaskResult, TaskResultAdmin -from nested_admin import NestedModelAdmin, NestedPolymorphicInlineSupportMixin +from django_celery_results.admin import TaskResultAdmin +from django_celery_results.models import TaskResult +from nested_admin.nested import NestedModelAdmin +from nested_admin.polymorphic import NestedPolymorphicInlineSupportMixin +from polymorphic.admin import PolymorphicInlineSupportMixin from sass_processor.processor import sass_processor from apps.admin.admin_site import admin_site @@ -46,13 +50,8 @@ ADMIN_CSS = 'styles/admin.css' -class ModelAdmin(NestedPolymorphicInlineSupportMixin, NestedModelAdmin): - """ - Base admin class for ModularHistory's models. - - Uses the NestedPolymorphicInlineSupportMixin as instructed in - https://django-nested-admin.readthedocs.io/en/latest/integrations.html. - """ +class ModelAdmin(PolymorphicInlineSupportMixin, BaseModelAdmin): + """Base admin class for ModularHistory's models.""" model: Type[Model] @@ -138,6 +137,27 @@ def get_search_results( queryset = queryset.exclude(pk=pk) return queryset, use_distinct + def save_form(self, request, form, change): + """ + Given a ModelForm return an unsaved instance. ``change`` is True if + the object is being changed, and False if it's being added. + """ + print(f'>>>>>>save_form>>>>>') + return super().save_form(request, form, change) + + def save_model(self, request, obj, form, change): + """ + Given a model instance save it to the database. + """ + print(f'>>>>>>after save_model>>>>> {getattr(obj, "summary", None)}') + obj.save() + print(f'>>>>>>after save_model>>>>> {getattr(obj, "summary", None)}') + obj.refresh_from_db() + print(f'>>>>>>after refresh_from_db>>>>> {getattr(obj, "summary", None)}') + from time import sleep + + sleep(5) + class ContentTypeFields(Constant): """Field names of the ContentType model.""" diff --git a/apps/entities/admin/entities.py b/apps/entities/admin/entities.py index b8f6123ac..e31c30a53 100644 --- a/apps/entities/admin/entities.py +++ b/apps/entities/admin/entities.py @@ -55,7 +55,7 @@ class EntityAdmin(SearchableModelAdmin): EntityTypeFilter, ] ordering = ['name', 'birth_date'] - readonly_fields = ['pretty_cache'] + readonly_fields = SearchableModelAdmin.readonly_fields + ['title', 'slug'] search_fields = ['name', 'aliases'] diff --git a/apps/entities/migrations/0004_auto_20210524_0113.py b/apps/entities/migrations/0004_auto_20210524_0113.py new file mode 100644 index 000000000..8b456094e --- /dev/null +++ b/apps/entities/migrations/0004_auto_20210524_0113.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.11 on 2021-05-24 01:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('entities', '0003_auto_20210521_1334'), + ] + + operations = [ + migrations.AddField( + model_name='entityimage', + name='position', + field=models.PositiveSmallIntegerField(blank=True, default=0, null=True), + ), + migrations.AlterField( + model_name='entity', + name='title', + field=models.CharField( + blank=True, + help_text='The title can be used for the detail page header and title tag, SERP result card header, etc. It should be a noun phrase!', + max_length=120, + null=True, + verbose_name='title', + ), + ), + ] diff --git a/apps/entities/models/entity_image.py b/apps/entities/models/entity_image.py index f38c152ad..6c4a5fcb9 100644 --- a/apps/entities/models/entity_image.py +++ b/apps/entities/models/entity_image.py @@ -1,13 +1,13 @@ from django.db import models -from core.models.model import Model +from core.models.positioned_relation import PositionedRelation NAME_MAX_LENGTH: int = 100 TRUNCATED_DESCRIPTION_LENGTH: int = 1200 -class EntityImage(Model): +class EntityImage(PositionedRelation): """An association of an image with an entity.""" entity = models.ForeignKey( diff --git a/apps/images/migrations/0003_auto_20210524_0113.py b/apps/images/migrations/0003_auto_20210524_0113.py new file mode 100644 index 000000000..06c7330fc --- /dev/null +++ b/apps/images/migrations/0003_auto_20210524_0113.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.11 on 2021-05-24 01:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('images', '0002_auto_20210522_1558'), + ] + + operations = [ + migrations.AlterField( + model_name='image', + name='title', + field=models.CharField( + blank=True, + help_text='The title can be used for the detail page header and title tag, SERP result card header, etc. It should be a noun phrase!', + max_length=120, + null=True, + verbose_name='title', + ), + ), + ] diff --git a/apps/occurrences/admin/__init__.py b/apps/occurrences/admin/__init__.py deleted file mode 100644 index d37cfd42d..000000000 --- a/apps/occurrences/admin/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .occurrences import OccurrenceAdmin diff --git a/apps/occurrences/api/views.py b/apps/occurrences/api/views.py index d999817d3..bfd136bbb 100644 --- a/apps/occurrences/api/views.py +++ b/apps/occurrences/api/views.py @@ -1,8 +1,8 @@ from rest_framework.generics import ListAPIView, RetrieveAPIView from rest_framework.viewsets import ModelViewSet -from apps.occurrences.models.occurrence import Occurrence -from apps.occurrences.serializers import OccurrenceSerializer +from apps.propositions.api.serializers import OccurrenceSerializer +from apps.propositions.models.occurrence import Occurrence class OccurrenceViewSet(ModelViewSet): diff --git a/apps/occurrences/constants.py b/apps/occurrences/constants.py deleted file mode 100644 index 0958e9312..000000000 --- a/apps/occurrences/constants.py +++ /dev/null @@ -1,18 +0,0 @@ -from enum import Enum - - -class OccurrenceTypes(Enum): - OCCURRENCE = 0 - BIRTH = 1 - DEATH = 2 - PUBLICATION = 3 - VERBALIZATION = 4 - - -OCCURRENCE_TYPES = ( - (OccurrenceTypes.OCCURRENCE.value, 'Occurrence (default)'), - (OccurrenceTypes.BIRTH.value, 'Birth'), - (OccurrenceTypes.DEATH.value, 'Death'), - (OccurrenceTypes.PUBLICATION.value, 'Publication'), - (OccurrenceTypes.VERBALIZATION.value, 'Verbalization'), -) diff --git a/apps/occurrences/managers.py b/apps/occurrences/managers.py deleted file mode 100644 index d5c31d1bc..000000000 --- a/apps/occurrences/managers.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import List, Optional - -from django.db.models import Q - -from apps.search.models.manager import SearchableModelManager, SearchableModelQuerySet -from core.models.manager import TypedModelManager - - -class OccurrenceManager(TypedModelManager, SearchableModelManager): - """Manager for occurrences.""" - - def search( - self, - query: Optional[str] = None, - start_year: Optional[int] = None, - end_year: Optional[int] = None, - entity_ids: Optional[List[int]] = None, - topic_ids: Optional[List[int]] = None, - rank: bool = False, - suppress_unverified: bool = True, - suppress_hidden: bool = True, - ) -> 'SearchableModelQuerySet': - """Return search results from apps.occurrences.""" - qs = ( - super() - .search( - query=query, - suppress_unverified=suppress_unverified, - suppress_hidden=suppress_hidden, - ) - .filter(hidden=False) - .filter_by_date(start_year=start_year, end_year=end_year) - .prefetch_related('citations', 'images') - ) - # Limit to specified entities - if entity_ids: - qs = qs.filter(Q(involved_entities__id__in=entity_ids)) - # Limit to specified topics - if topic_ids: - qs = qs.filter( - Q(tags__id__in=topic_ids) | Q(tags__related_topics__id__in=topic_ids) - ) - return qs - - @staticmethod - def prefetch_search_relatives(queryset): - return queryset.prefetch_related( - 'tags', - 'citations', - 'images', - ) diff --git a/apps/occurrences/migrations/0003_auto_20210523_0012.py b/apps/occurrences/migrations/0003_auto_20210523_0012.py new file mode 100644 index 000000000..ae4bd68c1 --- /dev/null +++ b/apps/occurrences/migrations/0003_auto_20210523_0012.py @@ -0,0 +1,31 @@ +# Generated by Django 3.1.11 on 2021-05-23 00:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('occurrences', '0002_auto_20210520_2048'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='occurrencechaininclusion', + unique_together=None, + ), + migrations.RemoveField( + model_name='occurrencechaininclusion', + name='chain', + ), + migrations.RemoveField( + model_name='occurrencechaininclusion', + name='occurrence', + ), + migrations.DeleteModel( + name='OccurrenceChain', + ), + migrations.DeleteModel( + name='OccurrenceChainInclusion', + ), + ] diff --git a/apps/occurrences/models/__init__.py b/apps/occurrences/models/__init__.py deleted file mode 100644 index 50db860ea..000000000 --- a/apps/occurrences/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Public models of the occurrences app.""" - -from .occurrence import Occurrence -from .occurrence_chain import OccurrenceChain, OccurrenceChainInclusion diff --git a/apps/occurrences/models/occurrence_chain.py b/apps/occurrences/models/occurrence_chain.py deleted file mode 100644 index ed0272311..000000000 --- a/apps/occurrences/models/occurrence_chain.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Occurrence chains.""" - -from typing import List - -from django.db import models - -from core.fields import HTMLField -from core.models.model import Model - -DESCRIPTION_MAX_LENGTH = 200 - - -class OccurrenceChain(Model): - """A chain of related occurrences.""" - - description = HTMLField(null=True, unique=True, paragraphed=True) - parent_chain = models.ForeignKey( - 'self', on_delete=models.CASCADE, related_name='sub_chains' - ) - - def __str__(self): - """Return the string representation of the occurrence chain.""" - return f'{self.description}' - - -class OccurrenceChainInclusion(Model): - """An inclusion of an occurrence in an occurrence chain.""" - - chain = models.ForeignKey( - to='occurrences.OccurrenceChain', - on_delete=models.CASCADE, - related_name='occurrence_inclusions', - ) - occurrence = models.ForeignKey( - to='propositions.Occurrence', - on_delete=models.CASCADE, - related_name='chain_inclusions', - ) - - class Meta: - """Meta options for OccurrenceChainInclusion.""" - - # https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options - - unique_together: List[str] = ['chain', 'occurrence'] - - def __str__(self): - """Return the string representation of the occurrence chain inclusion.""" - return f'{self.chain} : {self.occurrence}' diff --git a/apps/occurrences/models/occurrence_relations.py b/apps/occurrences/models/occurrence_relations.py deleted file mode 100644 index e24194c7c..000000000 --- a/apps/occurrences/models/occurrence_relations.py +++ /dev/null @@ -1,13 +0,0 @@ - -from core.models.model import Model -from core.models.positioned_relation import PositionedRelation - -IMPORTANCE_OPTIONS = ( - (1, 'Primary'), - (2, 'Secondary'), - (3, 'Tertiary'), - (4, 'Quaternary'), - (5, 'Quinary'), - (6, 'Senary'), - (7, 'Septenary'), -) diff --git a/apps/occurrences/serializers.py b/apps/occurrences/serializers.py deleted file mode 100644 index 55b5153c2..000000000 --- a/apps/occurrences/serializers.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Serializers for the occurrences app.""" - -import serpy - -from apps.search.api.serializers import SearchableModelSerializer - - -class OccurrenceSerializer(SearchableModelSerializer): - """Serializer for occurrences.""" - - dateString = serpy.StrField(attr='date_string') - summary = serpy.StrField() - elaboration = serpy.StrField() - postscript = serpy.StrField() - cachedImages = serpy.Field(attr='cached_images') - primaryImage = serpy.Field(attr='primary_image') - cachedCitations = serpy.Field(attr='cached_citations') - tagsHtml = serpy.StrField(attr='tags_html') - - def get_model(self, instance) -> str: # noqa - """Return the model name of the instance.""" - return 'occurrences.occurrence' diff --git a/apps/places/models/model_with_locations.py b/apps/places/models/model_with_locations.py index abef0b623..024174529 100644 --- a/apps/places/models/model_with_locations.py +++ b/apps/places/models/model_with_locations.py @@ -1,28 +1,68 @@ """Classes for models with related entities.""" import logging -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional, Type, Union +from django.db import models from django.utils.translation import ugettext_lazy as _ +from core.fields.custom_m2m_field import CustomManyToManyField +from core.fields.m2m_foreign_key import ManyToManyForeignKey from core.fields.sorted_m2m_field import SortedManyToManyField from core.models.model import Model from core.models.model_with_cache import store +from core.models.positioned_relation import PositionedRelation if TYPE_CHECKING: from django.db.models.manager import Manager +class AbstractLocationRelation(PositionedRelation): + """ + Abstract base model for locations relations, i.e., models governing + m2m relationships between `Place` and another model. + """ + + location = ManyToManyForeignKey( + to='places.Place', related_name='%(app_label)s_%(class)s_set' + ) + + # https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options + class Meta: + """Meta options for AbstractLocationRelation.""" + + abstract = True + + def content_object(self) -> models.ForeignKey: + """Foreign key to the model that references the location.""" + raise NotImplementedError + + +class LocationsField(CustomManyToManyField): + + target_model = 'places.Place' + through_model = AbstractLocationRelation + + def __init__(self, through: Union[Type[AbstractLocationRelation], str], **kwargs): + kwargs['through'] = through + kwargs['verbose_name'] = _('related quotes') + super().__init__(**kwargs) + + class ModelWithLocations(Model): """A model that has one or more associated locations.""" locations = SortedManyToManyField( to='places.Place', - related_name='%(class)s_set', + related_name='_%(class)s_set', blank=True, verbose_name=_('locations'), ) + @property + def _locations(self) -> LocationsField: + raise NotImplementedError + location_relations: 'Manager' class Meta: diff --git a/apps/propositions/admin/__init__.py b/apps/propositions/admin/__init__.py new file mode 100644 index 000000000..6a2320747 --- /dev/null +++ b/apps/propositions/admin/__init__.py @@ -0,0 +1,2 @@ +from .occurrences import OccurrenceAdmin +from .propositions import PropositionAdmin, TypedPropositionAdmin diff --git a/apps/occurrences/admin/filters.py b/apps/propositions/admin/filters.py similarity index 100% rename from apps/occurrences/admin/filters.py rename to apps/propositions/admin/filters.py diff --git a/apps/occurrences/admin/occurrences.py b/apps/propositions/admin/occurrences.py similarity index 69% rename from apps/occurrences/admin/occurrences.py rename to apps/propositions/admin/occurrences.py index e29bbc6aa..05d2b3b1c 100644 --- a/apps/occurrences/admin/occurrences.py +++ b/apps/propositions/admin/occurrences.py @@ -1,30 +1,22 @@ """Admin classes for occurrences.""" from apps.admin.admin_site import admin_site -from apps.occurrences import models -from apps.occurrences.admin.filters import ( +from apps.propositions import models +from apps.propositions.admin.filters import ( HasDateFilter, HasQuotesFilter, LocationFilter, ) -from apps.places.admin import AbstractLocationsInline -from apps.propositions.admin import TypedPropositionAdmin +from apps.propositions.admin.propositions import TypedPropositionAdmin from apps.sources.admin.filters import HasMultipleSourcesFilter, HasSourceFilter from apps.topics.models.taggable_model import TopicFilter -class LocationsInline(AbstractLocationsInline): - """Inline admin for an occurrence's locations.""" - - model = models.Occurrence.locations.through - - class OccurrenceAdmin(TypedPropositionAdmin): """Model admin for occurrences.""" model = models.Occurrence - inlines = TypedPropositionAdmin.inlines + [LocationsInline] list_display = [ 'slug', 'title', diff --git a/apps/propositions/admin.py b/apps/propositions/admin/propositions.py similarity index 81% rename from apps/propositions/admin.py rename to apps/propositions/admin/propositions.py index f840d6c89..5e4383f93 100644 --- a/apps/propositions/admin.py +++ b/apps/propositions/admin/propositions.py @@ -3,14 +3,15 @@ from apps.entities.admin.filters import RelatedEntityFilter from apps.entities.admin.inlines import AbstractRelatedEntitiesInline from apps.images.admin import AbstractImagesInline +from apps.places.admin import AbstractLocationsInline from apps.propositions import models from apps.search.admin import SearchableModelAdmin from apps.sources.admin.citations import AbstractSourcesInline -from apps.topics.admin.related_topics import AbstractRelatedTopicsInline +from apps.topics.admin.tags import AbstractTagsInline from apps.topics.models.taggable_model import TopicFilter -class RelatedTopicsInline(AbstractRelatedTopicsInline): +class TagsInline(AbstractTagsInline): """Inline admin for topic tags.""" model = models.TypedProposition.tags.through @@ -29,11 +30,17 @@ class RelatedEntitiesInline(AbstractRelatedEntitiesInline): class ImagesInline(AbstractImagesInline): - """Inline admin for an occurrence's images.""" + """Inline admin for images.""" model = models.TypedProposition.images.through +class LocationsInline(AbstractLocationsInline): + """Inline admin for locations.""" + + model = models.TypedProposition.locations.through + + class ConclusionsInline(TabularInline): """Inline admin for a proposition's supported propositions.""" @@ -45,6 +52,9 @@ class ConclusionsInline(TabularInline): autocomplete_fields = ['conclusion'] extra = 0 + def get_queryset(self, request): + return super().get_queryset(request).select_related('premise', 'conclusion') + class PremisesInline(TabularInline): """Inline admin for a proposition's premises.""" @@ -59,6 +69,9 @@ class PremisesInline(TabularInline): # https://django-grappelli.readthedocs.io/en/latest/customization.html#inline-sortables sortable_field_name = 'position' + def get_queryset(self, request): + return super().get_queryset(request).select_related('premise', 'conclusion') + class AbstractPropositionAdmin(SearchableModelAdmin): """Abstract base admin for propositions.""" @@ -75,7 +88,8 @@ class AbstractPropositionAdmin(SearchableModelAdmin): SourcesInline, ImagesInline, RelatedEntitiesInline, - RelatedTopicsInline, + TagsInline, + LocationsInline, ] list_display = [ 'slug', @@ -96,7 +110,7 @@ class TypedPropositionAdmin(AbstractPropositionAdmin): list_display = AbstractPropositionAdmin.list_display + ['date_string', 'type'] list_filter = AbstractPropositionAdmin.list_filter + ['type'] - ordering = ['type', 'date'] + ordering = ['date', 'type'] search_fields = model.searchable_fields diff --git a/apps/propositions/api/schema.py b/apps/propositions/api/schema.py index f03f0faf8..68aba1847 100644 --- a/apps/propositions/api/schema.py +++ b/apps/propositions/api/schema.py @@ -1,4 +1,3 @@ - import graphene from django.core.exceptions import ObjectDoesNotExist diff --git a/apps/propositions/api/serializers.py b/apps/propositions/api/serializers.py index c3d13e336..10666844a 100644 --- a/apps/propositions/api/serializers.py +++ b/apps/propositions/api/serializers.py @@ -1,5 +1,6 @@ import serpy +from apps.search.api.serializers import SearchableModelSerializer from core.models.model import ModelSerializer @@ -13,3 +14,20 @@ class PropositionSerializer(ModelSerializer): def get_model(self, instance) -> str: """Return the model name of serialized propositions.""" return 'propositions.proposition' + + +class OccurrenceSerializer(SearchableModelSerializer): + """Serializer for occurrences.""" + + dateString = serpy.StrField(attr='date_string') + summary = serpy.StrField() + elaboration = serpy.StrField() + postscript = serpy.StrField() + cachedImages = serpy.Field(attr='cached_images') + primaryImage = serpy.Field(attr='primary_image') + cachedCitations = serpy.Field(attr='cached_citations') + tagsHtml = serpy.StrField(attr='tags_html') + + def get_model(self, instance) -> str: # noqa + """Return the model name of the instance.""" + return 'occurrences.occurrence' diff --git a/apps/propositions/migrations/0001_initial.py b/apps/propositions/migrations/0001_initial.py index 36019eeae..22675e075 100644 --- a/apps/propositions/migrations/0001_initial.py +++ b/apps/propositions/migrations/0001_initial.py @@ -1,7 +1,6 @@ # Generated by Django 3.1.9 on 2021-05-20 20:08 import autoslug.fields -import concurrency.fields from django.db import migrations, models import apps.dates.fields @@ -191,7 +190,7 @@ class Migration(migrations.Migration): ), ( 'version', - concurrency.fields.IntegerVersionField( + models.PositiveSmallIntegerField( default=0, help_text='record revision number' ), ), diff --git a/apps/propositions/migrations/0011_auto_20210524_0113.py b/apps/propositions/migrations/0011_auto_20210524_0113.py new file mode 100644 index 000000000..b074a1f98 --- /dev/null +++ b/apps/propositions/migrations/0011_auto_20210524_0113.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.11 on 2021-05-24 01:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('propositions', '0010_auto_20210522_1923'), + ] + + operations = [ + migrations.AlterField( + model_name='typedproposition', + name='title', + field=models.CharField( + blank=True, + help_text='The title can be used for the detail page header and title tag, SERP result card header, etc. It should be a noun phrase!', + max_length=120, + null=True, + verbose_name='title', + ), + ), + ] diff --git a/apps/propositions/migrations/0012_auto_20210525_0314.py b/apps/propositions/migrations/0012_auto_20210525_0314.py new file mode 100644 index 000000000..934140b48 --- /dev/null +++ b/apps/propositions/migrations/0012_auto_20210525_0314.py @@ -0,0 +1,80 @@ +# Generated by Django 3.1.11 on 2021-05-25 03:14 + +import django.db.models.deletion +from django.db import migrations, models + +import apps.places.models.model_with_locations +import core.fields.m2m_foreign_key +import core.fields.sorted_m2m_field + + +class Migration(migrations.Migration): + + dependencies = [ + ('places', '0003_auto_20210519_1729'), + ('propositions', '0011_auto_20210524_0113'), + ] + + operations = [ + migrations.AlterField( + model_name='typedproposition', + name='locations', + field=core.fields.sorted_m2m_field.SortedManyToManyField( + blank=True, + help_text=None, + related_name='_typedproposition_set', + sort_value_field_name='position', + to='places.Place', + verbose_name='locations', + ), + ), + migrations.CreateModel( + name='Location', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'position', + models.PositiveSmallIntegerField(blank=True, default=0, null=True), + ), + ( + 'content_object', + core.fields.m2m_foreign_key.ManyToManyForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='_location_relations', + to='propositions.typedproposition', + verbose_name='proposition', + ), + ), + ( + 'location', + core.fields.m2m_foreign_key.ManyToManyForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='propositions_location_set', + to='places.place', + ), + ), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='typedproposition', + name='_locations', + field=apps.places.models.model_with_locations.LocationsField( + blank=True, + related_name='typedproposition_set', + through='propositions.Location', + to='places.Place', + verbose_name='related quotes', + ), + ), + ] diff --git a/apps/propositions/migrations/0013_auto_20210525_0341.py b/apps/propositions/migrations/0013_auto_20210525_0341.py new file mode 100644 index 000000000..91010dd1d --- /dev/null +++ b/apps/propositions/migrations/0013_auto_20210525_0341.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.11 on 2021-05-25 03:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('propositions', '0012_auto_20210525_0314'), + ] + + operations = [ + migrations.AlterField( + model_name='typedproposition', + name='type', + field=models.CharField( + choices=[ + ('propositions.proposition', 'proposition'), + ('propositions.occurrence', 'occurrence'), + ('propositions.birth', 'birth'), + ('propositions.death', 'death'), + ('propositions.publication', 'publication'), + ('propositions.verbalization', 'verbalization'), + ], + db_index=True, + max_length=100, + ), + ), + ] diff --git a/apps/propositions/models/__init__.py b/apps/propositions/models/__init__.py index 10684d529..6d46bc9f8 100644 --- a/apps/propositions/models/__init__.py +++ b/apps/propositions/models/__init__.py @@ -1,5 +1,12 @@ """Model classes of the propositions app, importable from `propositions.models`.""" from .model_with_propositions import ModelWithPropositions -from .proposition import Citation, Proposition, QuoteRelation, TypedProposition +from .occurrence import Occurrence +from .proposition import ( + Citation, + Location, + Proposition, + QuoteRelation, + TypedProposition, +) from .support import Support diff --git a/apps/occurrences/models/occurrence.py b/apps/propositions/models/occurrence.py similarity index 86% rename from apps/occurrences/models/occurrence.py rename to apps/propositions/models/occurrence.py index b76566ae5..156dd5c59 100644 --- a/apps/occurrences/models/occurrence.py +++ b/apps/propositions/models/occurrence.py @@ -1,13 +1,12 @@ from typing import TYPE_CHECKING, Optional from django.core.exceptions import ValidationError +from django.db.models import Manager from django.template.defaultfilters import truncatechars_html from django.utils.html import format_html from django.utils.safestring import SafeString -from django.utils.translation import ugettext_lazy as _ -from apps.occurrences import managers -from apps.occurrences.serializers import OccurrenceSerializer +from apps.propositions.api.serializers import OccurrenceSerializer from apps.propositions.models.proposition import TypedProposition from core.fields import HTMLField from core.utils.html import soupify @@ -19,6 +18,11 @@ TRUNCATED_DESCRIPTION_LENGTH: int = 250 +class OccurrenceManager(Manager): + def get_queryset(self): + return super().get_queryset().filter(type='propositions.occurrence') + + class Occurrence(TypedProposition): """ An occurrence, i.e., something that has happened. @@ -28,21 +32,14 @@ class Occurrence(TypedProposition): from `Proposition`. """ - postscript = HTMLField( - verbose_name=_('postscript'), - null=True, - blank=True, - paragraphed=True, - help_text='Content to be displayed below all related data', - ) - # https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options class Meta: """Meta options for the `Occurrence` model.""" + proxy = True ordering = ['date'] - objects = managers.OccurrenceManager() + objects = OccurrenceManager() searchable_fields = [ 'summary', 'elaboration', @@ -74,6 +71,7 @@ def save(self, *args, **kwargs): image = entity.image if image: self.images.add(image) + print(f'>>>>> post save: {self.summary}') def clean(self): """Prepare the occurrence to be saved.""" @@ -105,14 +103,26 @@ def ordered_images(self): class Birth(Occurrence): """A birth of an entity.""" + class Meta: + proxy = True + class Death(Occurrence): """A death of an entity.""" + class Meta: + proxy = True + class Publication(Occurrence): """A publication of a source.""" + class Meta: + proxy = True + class Verbalization(Occurrence): """A verbalization or production of a source, prior to publication.""" + + class Meta: + proxy = True diff --git a/apps/propositions/models/proposition.py b/apps/propositions/models/proposition.py index b97ddcd3b..0a391e729 100644 --- a/apps/propositions/models/proposition.py +++ b/apps/propositions/models/proposition.py @@ -12,7 +12,11 @@ from apps.dates.models import DatedModel from apps.entities.models.model_with_related_entities import ModelWithRelatedEntities from apps.images.models.model_with_images import ModelWithImages -from apps.places.models.model_with_locations import ModelWithLocations +from apps.places.models.model_with_locations import ( + AbstractLocationRelation, + LocationsField, + ModelWithLocations, +) from apps.propositions.api.serializers import PropositionSerializer from apps.quotes.models.model_with_related_quotes import ( AbstractQuoteRelation, @@ -57,19 +61,34 @@ def get_proposition_fk(related_name: str): class Citation(AbstractCitation): - """A relation of a source with a proposition.""" + """A relationship between a proposition and a source.""" + + content_object = get_proposition_fk(related_name='citations') + + +class Location(AbstractLocationRelation): + """A relationship between a proposition and a place.""" - content_object = get_proposition_fk('citations') + content_object = get_proposition_fk(related_name='_location_relations') class QuoteRelation(AbstractQuoteRelation): - """A relation of a quote with a proposition.""" + """A relationship between a proposition and a quote.""" - content_object = get_proposition_fk('quote_relations') + content_object = get_proposition_fk(related_name='quote_relations') + + +TYPE_CHOICES = ( + ('propositions.proposition', 'proposition'), + ('propositions.occurrence', 'occurrence'), + ('propositions.birth', 'birth'), + ('propositions.death', 'death'), + ('propositions.publication', 'publication'), + ('propositions.verbalization', 'verbalization'), +) class TypedProposition( - TypedModel, SearchableModel, DatedModel, # submodels like `Occurrence` require date ModelWithSources, @@ -86,16 +105,35 @@ class TypedProposition( should inherit from this model. """ + type = models.CharField( + choices=TYPE_CHOICES, + db_index=True, + max_length=100, + ) summary = HTMLField( - verbose_name=_('summary'), unique=True, paragraphed=False, processed=False + verbose_name=_('summary'), + unique=True, + paragraphed=False, + processed=False, + ) + elaboration = HTMLField( + verbose_name=_('elaboration'), + null=True, + paragraphed=True, ) - elaboration = HTMLField(verbose_name=_('elaboration'), null=True, paragraphed=True) certainty = models.PositiveSmallIntegerField( verbose_name=_('certainty'), null=True, blank=True, choices=DEGREES_OF_CERTAINTY, ) + postscript = HTMLField( + verbose_name=_('postscript'), + null=True, + blank=True, + paragraphed=True, + help_text='Content to be displayed below all related data', + ) premises = models.ManyToManyField( to='self', through='propositions.Support', @@ -103,6 +141,8 @@ class TypedProposition( symmetrical=False, verbose_name=_('premises'), ) + + _locations = LocationsField(through=Location) related_quotes = RelatedQuotesField( through=QuoteRelation, related_name='propositions', @@ -122,7 +162,7 @@ class TypedProposition( 'tags__aliases', ] serializer = PropositionSerializer - slug_base_field = 'summary' + slug_base_field = 'title' def __str__(self) -> str: """Return the proposition's string representation.""" @@ -135,6 +175,9 @@ def clean(self): raise ValidationError('Proposition needs a degree of certainty.') return super().clean() + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + @property def summary_link(self) -> str: """Return an HTML link to the proposition, containing the summary text.""" @@ -207,3 +250,6 @@ def get_updated_placeholder(cls, match: Match) -> str: class Proposition(TypedProposition): """A proposition.""" + + class Meta: + proxy = True diff --git a/apps/propositions/tests.py b/apps/propositions/tests.py index a39b155ac..b1efb363f 100644 --- a/apps/propositions/tests.py +++ b/apps/propositions/tests.py @@ -1 +1,14 @@ -# Create your tests here. +import pytest + +from apps.propositions.models.proposition import TypedProposition +from core.tests import TestSuite + + +@pytest.mark.django_db() +class TestPropositions(TestSuite): + """Test the propositions app.""" + + def test_query_performance(self): + """Test the performance of a simple query.""" + with self.record_performance(): + list(TypedProposition.objects.all()) diff --git a/apps/quotes/admin/quotes.py b/apps/quotes/admin/quotes.py index 9750706fe..172fe568e 100644 --- a/apps/quotes/admin/quotes.py +++ b/apps/quotes/admin/quotes.py @@ -18,7 +18,7 @@ HasMultipleSourcesFilter, HasSourceFilter, ) -from apps.topics.admin.related_topics import AbstractRelatedTopicsInline, HasTagsFilter +from apps.topics.admin.tags import AbstractTagsInline, HasTagsFilter from apps.topics.models.taggable_model import TopicFilter @@ -41,7 +41,7 @@ class RelatedQuotesInline(AbstractRelatedQuotesInline): fk_name = 'content_object' -class RelatedTopicsInline(AbstractRelatedTopicsInline): +class TagsInline(AbstractTagsInline): """Inline admin for a quote's related entities.""" model = models.Quote.tags.through @@ -61,7 +61,7 @@ class QuoteAdmin(SearchableModelAdmin): SourcesInline, RelatedQuotesInline, RelatedEntitiesInline, - RelatedTopicsInline, + TagsInline, BitesInline, ] list_display = [ diff --git a/apps/quotes/migrations/0001_initial.py b/apps/quotes/migrations/0001_initial.py index 7ad51f148..9f219f2c7 100644 --- a/apps/quotes/migrations/0001_initial.py +++ b/apps/quotes/migrations/0001_initial.py @@ -1,7 +1,6 @@ # Generated by Django 3.1.9 on 2021-05-20 20:08 import autoslug.fields -import concurrency.fields import django.db.models.deletion from django.db import migrations, models @@ -130,7 +129,7 @@ class Migration(migrations.Migration): ), ( 'version', - concurrency.fields.IntegerVersionField( + models.PositiveSmallIntegerField( default=0, help_text='record revision number' ), ), diff --git a/apps/quotes/migrations/0006_auto_20210524_0113.py b/apps/quotes/migrations/0006_auto_20210524_0113.py new file mode 100644 index 000000000..38eb6a24e --- /dev/null +++ b/apps/quotes/migrations/0006_auto_20210524_0113.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.11 on 2021-05-24 01:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('quotes', '0005_auto_20210522_0302'), + ] + + operations = [ + migrations.AlterField( + model_name='quote', + name='title', + field=models.CharField( + blank=True, + help_text='The title can be used for the detail page header and title tag, SERP result card header, etc. It should be a noun phrase!', + max_length=120, + null=True, + verbose_name='title', + ), + ), + ] diff --git a/apps/quotes/models/model_with_related_quotes.py b/apps/quotes/models/model_with_related_quotes.py index 57a935d5f..62eb0049a 100644 --- a/apps/quotes/models/model_with_related_quotes.py +++ b/apps/quotes/models/model_with_related_quotes.py @@ -34,13 +34,10 @@ def content_object(self) -> models.ForeignKey: class RelatedQuotesField(CustomManyToManyField): + target_model = 'quotes.Quote' through_model = AbstractQuoteRelation def __init__(self, through: Union[Type[AbstractQuoteRelation], str], **kwargs): - to = kwargs.get('to', 'quotes.Quote') - if to not in ('self', 'quotes.Quote'): - raise ValueError(f'{to} does not refer to the `Quote` model.') - kwargs['to'] = to kwargs['through'] = through kwargs['verbose_name'] = _('related quotes') super().__init__(**kwargs) diff --git a/apps/quotes/models/quote.py b/apps/quotes/models/quote.py index e200023fd..712c35300 100644 --- a/apps/quotes/models/quote.py +++ b/apps/quotes/models/quote.py @@ -280,7 +280,7 @@ def ordered_attributees(self) -> List['Entity']: def related_occurrences(self) -> 'QuerySet': """Return a queryset of the quote's related occurrences.""" # TODO: refactor - from apps.occurrences.models.occurrence import Occurrence + from apps.propositions.models.occurrence import Occurrence return Occurrence.objects.filter(related_quotes__pk=self.pk) diff --git a/apps/search/admin.py b/apps/search/admin.py index ff5b24f3b..fbc474e28 100644 --- a/apps/search/admin.py +++ b/apps/search/admin.py @@ -28,11 +28,9 @@ def get_fields(self, request, model_instance=None): 'type', 'title', 'slug', - 'verified', - 'hidden', - 'date_is_circa', - 'date', - 'end_date', + 'summary', + 'certainty', + 'elaboration', ] ) for field_name in ordered_field_names: @@ -41,6 +39,49 @@ def get_fields(self, request, model_instance=None): fields.insert(0, field_name) return fields + def get_fieldsets(self, request, model_instance=None): + fields, fieldsets = list(self.get_fields(request, model_instance)), [] + meta_fields = [ + fields.pop(fields.index(field)) + for field in ('notes', 'verified', 'hidden') + if field in fields + ] + if meta_fields: + fieldsets.append(('Meta', {'fields': meta_fields})) + essential_fields = [ + fields.pop(fields.index(field)) + for field in ('type', 'title', 'slug') + if field in fields + ] + if essential_fields: + fieldsets.append((None, {'fields': essential_fields})) + date_fields = [ + fields.pop(fields.index(field)) + for field in ('date_is_circa', 'date', 'end_date') + if field in fields + ] + if date_fields: + fieldsets.append( + ('Date', {'fields': date_fields}), + ) + collapsed_fields = [ + fields.pop(fields.index(field)) + for field in ('pretty_cache', 'cache') + if field in fields + ] + fieldsets.append((None, {'fields': fields})) + if collapsed_fields: + fieldsets.append( + ( + None, + { + 'classes': ('collapse',), + 'fields': collapsed_fields, + }, + ) + ) + return fieldsets + def get_urls(self): """Return URLs used by searchable model admins.""" urls = super().get_urls() diff --git a/apps/search/documents/occurrence.py b/apps/search/documents/occurrence.py index cf4e565be..64bb93758 100644 --- a/apps/search/documents/occurrence.py +++ b/apps/search/documents/occurrence.py @@ -2,7 +2,7 @@ from django_elasticsearch_dsl.registries import registry from apps.entities.models.entity import Entity -from apps.occurrences.models.occurrence import Occurrence +from apps.propositions.models.occurrence import Occurrence from apps.search.documents.base import Document from apps.search.documents.config import ( DEFAULT_INDEX_SETTINGS, diff --git a/apps/search/models/searchable_model.py b/apps/search/models/searchable_model.py index 0d66e5c1e..78a28831a 100644 --- a/apps/search/models/searchable_model.py +++ b/apps/search/models/searchable_model.py @@ -27,7 +27,7 @@ class SearchableModel(TaggableModel, VerifiableModel): blank=True, help_text=( 'The title can be used for the detail page header and title tag, ' - 'SERP result card header, etc.' + 'SERP result card header, etc. It should be a noun phrase!' ), ) hidden = models.BooleanField( diff --git a/apps/sources/admin/citations.py b/apps/sources/admin/citations.py index 9527af194..32a7c2932 100644 --- a/apps/sources/admin/citations.py +++ b/apps/sources/admin/citations.py @@ -1,10 +1,7 @@ -from typing import TYPE_CHECKING, List, Type +from typing import List, Type from apps.admin.inlines import TabularInline -if TYPE_CHECKING: - pass - class AbstractSourcesInline(TabularInline): """Inline admin for sources.""" @@ -22,10 +19,13 @@ class AbstractSourcesInline(TabularInline): sortable_field_name = 'position' def get_fields(self, request, model_instance) -> List[str]: - fields = super().get_fields(request, model_instance=model_instance) + fields = super().get_fields(request, model_instance) ordered_fields = ['citation_phrase'] for field in ordered_fields: if field in fields: fields.remove(field) fields.insert(0, field) return fields + + def get_queryset(self, request): + return super().get_queryset(request).select_related('source') diff --git a/apps/sources/migrations/0005_webpage_website_name.py b/apps/sources/migrations/0005_webpage_website_name.py new file mode 100644 index 000000000..5087e7090 --- /dev/null +++ b/apps/sources/migrations/0005_webpage_website_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.11 on 2021-05-24 22:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sources', '0004_auto_20210522_1844'), + ] + + operations = [ + migrations.AddField( + model_name='webpage', + name='website_name', + field=models.CharField(blank=True, max_length=20), + ), + ] diff --git a/apps/sources/models/model_with_sources.py b/apps/sources/models/model_with_sources.py index c9d1b6e07..15336984d 100644 --- a/apps/sources/models/model_with_sources.py +++ b/apps/sources/models/model_with_sources.py @@ -20,11 +20,11 @@ class SourcesField(CustomManyToManyField): """Field for sources.""" + target_model = 'sources.Source' through_model = AbstractCitation def __init__(self, through: Union[Type[AbstractCitation], str], **kwargs): """Construct the field.""" - kwargs['to'] = 'sources.Source' kwargs['through'] = through kwargs['verbose_name'] = _('sources') super().__init__(**kwargs) diff --git a/apps/sources/models/sources/webpage.py b/apps/sources/models/sources/webpage.py index 7a18d26a2..4283cfe00 100644 --- a/apps/sources/models/sources/webpage.py +++ b/apps/sources/models/sources/webpage.py @@ -1,5 +1,6 @@ """Model classes for webpages.""" +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import ugettext_lazy as _ @@ -22,18 +23,28 @@ def __str__(self) -> str: class Webpage(Source, TextualMixin): """A webpage.""" + website_name = models.CharField(max_length=20, blank=True) website = models.ForeignKey( to='sources.Website', null=True, blank=True, on_delete=models.CASCADE ) date_nullable = True + def clean(self): + super().clean() + if not self.website and not self.website_name: + raise ValidationError('Please specify either `website` or `website_name`.') + + def save(self, *args, **kwargs): + self.website_name = self.website.name if self.website else self.website_name + super().save(*args, **kwargs) + def __html__(self) -> str: """Return the source's HTML representation.""" components = [ self.attributee_html, f'"{self.linked_title}"', - f'{self.website.name}', + f'{self.website_name}' if self.website_name else '', self.website.owner, self.date.string if self.date else '', f'retrieved from {self.url}', diff --git a/apps/stories/migrations/0001_initial.py b/apps/stories/migrations/0001_initial.py index 76f41f518..36f80cd02 100644 --- a/apps/stories/migrations/0001_initial.py +++ b/apps/stories/migrations/0001_initial.py @@ -1,6 +1,5 @@ # Generated by Django 3.1.9 on 2021-05-20 20:08 -import concurrency.fields import django.db.models.deletion from django.db import migrations, models @@ -33,7 +32,7 @@ class Migration(migrations.Migration): ), ( 'version', - concurrency.fields.IntegerVersionField( + models.PositiveSmallIntegerField( default=0, help_text='record revision number' ), ), diff --git a/apps/topics/admin/__init__.py b/apps/topics/admin/__init__.py index eedb99d4f..0e536e6f6 100644 --- a/apps/topics/admin/__init__.py +++ b/apps/topics/admin/__init__.py @@ -1,2 +1,2 @@ -from .related_topics import AbstractRelatedTopicsInline, HasTagsFilter +from .tags import AbstractTagsInline, HasTagsFilter from .topic_admin import TopicAdmin diff --git a/apps/topics/admin/related_topics.py b/apps/topics/admin/tags.py similarity index 66% rename from apps/topics/admin/related_topics.py rename to apps/topics/admin/tags.py index aa1c8cc4d..aa8d469e2 100644 --- a/apps/topics/admin/related_topics.py +++ b/apps/topics/admin/tags.py @@ -1,16 +1,15 @@ -from typing import TYPE_CHECKING, Optional, Type +from typing import Type from django.contrib.admin import SimpleListFilter from django.db.models import Model +from django.db.models.query import QuerySet +from django.http.request import HttpRequest from apps.admin.inlines import TabularInline from core.constants.strings import NO, YES -if TYPE_CHECKING: - from apps.topics.models.taggable_model import TaggableModel - -class AbstractRelatedTopicsInline(TabularInline): +class AbstractTagsInline(TabularInline): """Abstract base inline for related topics.""" model: Type[Model] @@ -18,13 +17,8 @@ class AbstractRelatedTopicsInline(TabularInline): verbose_name = 'tag' verbose_name_plural = 'tags' - def get_extra( - self, request, model_instance: Optional['TaggableModel'] = None, **kwargs - ) -> int: - """Return the number of extra (blank) input rows to display.""" - if model_instance and model_instance.tags.count(): - return 0 - return 1 + def get_queryset(self, request: HttpRequest) -> QuerySet: + return super().get_queryset(request).select_related('topic') class HasTagsFilter(SimpleListFilter): diff --git a/config/hooks/pre-commit b/config/hooks/pre-commit index b1fefc66d..b4f55c23b 100644 --- a/config/hooks/pre-commit +++ b/config/hooks/pre-commit @@ -4,8 +4,8 @@ staged_files="$(git diff --diff-filter=AM --name-only --staged)" bold=$(tput bold) normal=$(tput sgr0) -echo "$staged_files" | grep --quiet ".py" && { - staged_python_files=$(echo "$staged_files" | grep '.py') +echo "$staged_files" | grep -E --quiet ".+.py" && { + staged_python_files=$(echo "$staged_files" | grep -E ".+.py") # Autoformat and lint Python code. poetry --help &>/dev/null && [[ -d ".venv" ]] && { comma_delimited_filepaths=$(echo "$staged_python_files" | tr '\n' ',') diff --git a/config/nginx/dev/nginx.conf b/config/nginx/dev/nginx.conf index 1a5b39065..4635709f6 100644 --- a/config/nginx/dev/nginx.conf +++ b/config/nginx/dev/nginx.conf @@ -109,9 +109,9 @@ server { # Route matching requests to the Django server: # - paths beginning with `/api/`, except for paths beginning with `/api/auth` # - paths beginning with `/graphql` or `/graphiql` - # - paths beginning with `/admin` + # - paths beginning with `/admin` or `/_admin` # - paths beginning with `/static` or `/media` (only in dev environment!) - location ~ ^/(api/(?!auth).*|graphi?ql.*|admin.*|static/.*|media/.*) { + location ~ ^/(api/(?!auth).*|graphi?ql.*|_?admin.*|static/.*|media/.*) { proxy_pass http://django_server; proxy_redirect off; proxy_set_header Host $host; diff --git a/config/nginx/prod/nginx.conf b/config/nginx/prod/nginx.conf index 0601f7a41..0a67aaec7 100644 --- a/config/nginx/prod/nginx.conf +++ b/config/nginx/prod/nginx.conf @@ -110,7 +110,7 @@ server { # Serve robots.txt. location /robots.txt { add_header Content-Type text/plain; - return 200 "User-agent: *\nDisallow: /admin/\n"; + return 200 "User-agent: *\nDisallow: /admin/\nDisallow: /_admin/\n"; } # Serve error page. @@ -155,8 +155,8 @@ server { # Route matching requests to the Django server: # - paths beginning with `/api/`, except for paths beginning with `/api/auth` # - paths beginning with `/graphql` - # - paths beginning with `/admin` - location ~ ^/(api/(?!auth).*|graphql.*|admin.*) { + # - paths beginning with `/admin` or `/_admin` + location ~ ^/(api/(?!auth).*|graphql.*|_?admin.*) { proxy_pass http://django_server; proxy_redirect off; proxy_set_header Host $host; diff --git a/core/config/_debug_toolbar.py b/core/config/_debug_toolbar.py index 7d96ae591..d3f30f1a4 100644 --- a/core/config/_debug_toolbar.py +++ b/core/config/_debug_toolbar.py @@ -8,18 +8,30 @@ Config reference: https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html """ +from decouple import config from django.conf import settings from django.http import HttpRequest +ENABLE_DEBUG_TOOLBAR = config( + 'ENABLE_DEBUG_TOOLBAR', + cast=bool, + default=settings.DEBUG, +) + def show_toolbar(request: HttpRequest) -> bool: """Determine whether to display the debug toolbar.""" - conditions = ( + qualifiers = ( settings.DEBUG and request.META.get('REMOTE_ADDR', None) in settings.INTERNAL_IPS, request.user.is_superuser, ) - disqualifiers = (settings.TESTING,) - if any(conditions) and not any(disqualifiers): + disqualifiers = ( + ENABLE_DEBUG_TOOLBAR == False, + settings.TESTING, + '/api/' in request.path, + request.path == '/healthcheck/', + ) + if any(qualifiers) and not any(disqualifiers): return True return False diff --git a/core/config/_watchman.py b/core/config/_watchman.py index bbfb1121b..270c5f1f9 100644 --- a/core/config/_watchman.py +++ b/core/config/_watchman.py @@ -14,7 +14,7 @@ @check def check_health_checks(): """Check health checks listed under `health_check` in INSTALLED_APPS.""" - stati = {'debug': {HEALTHY: True}} + statuses = {'debug': {HEALTHY: True}} output = StringIO() # https://github.com/KristianOellegaard/django-health-check#django-command management.call_command(HEALTH_CHECK_COMMAND, stdout=output) @@ -30,5 +30,5 @@ def check_health_checks(): continue key, status = match.group(1), match.group(2) key = stringcase.snakecase(key.replace('HealthCheck', '')) - stati[key] = {HEALTHY: status == 'working'} - return stati + statuses[key] = {HEALTHY: status == 'working'} + return statuses diff --git a/core/config/admin.py b/core/config/admin.py index 516c39777..5de24a011 100644 --- a/core/config/admin.py +++ b/core/config/admin.py @@ -1,4 +1,7 @@ """Admin-related settings.""" +from decouple import config + +from core.environment import ENVIRONMENT # https://github.com/cdrx/django-admin-menu ADMIN_LOGO = 'logo_head_white.png' @@ -6,3 +9,10 @@ # https://django-admin-tools.readthedocs.io/en/latest/customization.html ADMIN_TOOLS_MENU = 'apps.admin.admin_menu.AdminMenu' ADMIN_TOOLS_THEMING_CSS = 'styles/admin.css' + +# https://github.com/dizballanze/django-admin-env-notice +ENVIRONMENT_NAME = f'{ENVIRONMENT.title()} server' +ENVIRONMENT_COLOR = '#FF2222' + +# https://github.com/dmpayton/django-admin-honeypot +ADMIN_URL_PREFIX = config('ADMIN_URL_PREFIX', default='_admin') diff --git a/core/config/caches.py b/core/config/caches.py index e8ffc9ded..a54f9c826 100644 --- a/core/config/caches.py +++ b/core/config/caches.py @@ -38,7 +38,7 @@ # Cachalot settings: # https://django-cachalot.readthedocs.io/en/latest/quickstart.html#settings -CACHALOT_ENABLED = False and ENVIRONMENT != Environments.GITHUB_TEST +CACHALOT_ENABLED = ENVIRONMENT != Environments.GITHUB_TEST and not use_dummy_cache CACHALOT_CACHE = 'default' # cache name CACHALOT_CACHE_RANDOM = False # caching of random order queries, i.e order_by('?') CACHALOT_UNCACHABLE_TABLES = frozenset( @@ -55,6 +55,5 @@ 'django_celery_results_chordcounter', 'django_celery_results_taskresult', 'health_check_db_testmodel', - 'propositions_typed_proposition', ) ) diff --git a/core/config/debug_toolbar.py b/core/config/debugging.py similarity index 87% rename from core/config/debug_toolbar.py rename to core/config/debugging.py index d87ad691d..45c5c0916 100644 --- a/core/config/debug_toolbar.py +++ b/core/config/debugging.py @@ -1,4 +1,4 @@ -"""Settings for the Django Debug Toolbar.""" +"""Settings for debugging.""" # https://docs.djangoproject.com/en/3.1/ref/settings#s-internal-ips # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#configuring-internal-ips @@ -13,6 +13,7 @@ 'debug_toolbar.panels.timer.TimerPanel', 'debug_toolbar.panels.request.RequestPanel', 'debug_toolbar.panels.headers.HeadersPanel', + # https://github.com/jazzband/django-debug-toolbar/issues/1374 'debug_toolbar.panels.sql.SQLPanel', 'debug_toolbar.panels.staticfiles.StaticFilesPanel', 'debug_toolbar.panels.templates.TemplatesPanel', @@ -27,3 +28,7 @@ 'cachalot.panels.CachalotPanel', # https://django-cachalot.readthedocs.io/en/latest/quickstart.html # noqa: E501 # 'pympler.panels.MemoryPanel', #https://pympler.readthedocs.io/en/latest/django.html#django # noqa: E501 ] + +# https://github.com/jazzband/django-silk +SILKY_PYTHON_PROFILER = True +SILKY_ANALYZE_QUERIES = True diff --git a/core/config/tinymce.py b/core/config/tinymce.py index 2a64d1f53..b769d4bb3 100644 --- a/core/config/tinymce.py +++ b/core/config/tinymce.py @@ -9,6 +9,8 @@ TINYMCE_DEFAULT_CONFIG = { 'width': '100%', 'max_height': 1000, + 'extended_valid_elements': 'module[data-id|data-type],proposition[data-id],citation[data-id]', + 'custom_elements': 'module,~proposition,~citation', 'cleanup_on_startup': True, 'custom_undo_redo_levels': 20, 'selector': 'textarea.tinymce', diff --git a/core/constants/content_types.py b/core/constants/content_types.py index 617e70164..99686a79f 100644 --- a/core/constants/content_types.py +++ b/core/constants/content_types.py @@ -38,7 +38,7 @@ class ContentTypes(Constant): # ModelNameSet.citation: 'apps.sources.models.Citation', # TODO: clean up ModelNameSet.entity: 'apps.entities.models.Entity', ModelNameSet.image: 'apps.images.models.Image', - ModelNameSet.occurrence: 'apps.occurrences.models.Occurrence', + ModelNameSet.occurrence: 'apps.propositions.models.Occurrence', ModelNameSet.place: 'apps.places.models.Place', ModelNameSet.quote: 'apps.quotes.models.Quote', ModelNameSet.source: 'apps.sources.models.Source', diff --git a/core/fields/custom_m2m_field.py b/core/fields/custom_m2m_field.py index 3e1181b3e..ad85f1881 100644 --- a/core/fields/custom_m2m_field.py +++ b/core/fields/custom_m2m_field.py @@ -1,4 +1,4 @@ -from typing import Type +from typing import Type, Union from django.db import models from django.utils.module_loading import import_string @@ -6,9 +6,14 @@ class CustomManyToManyField(models.ManyToManyField): - through_model: Type[models.Model] + target_model: Union[str, Type[models.Model]] + through_model: Union[str, Type[models.Model]] def __init__(self, **kwargs): + to = kwargs.get('to', self.target_model) + if to not in ('self', self.target_model): + raise ValueError(f'{to} does not refer to `{self.target_model}`.') + kwargs['to'] = to kwargs['related_name'] = kwargs.get('related_name', '%(class)s_set') kwargs['blank'] = kwargs.get('blank', True) through = kwargs.get('through') diff --git a/core/settings.py b/core/settings.py index 4f4942b40..cfdad5101 100644 --- a/core/settings.py +++ b/core/settings.py @@ -82,11 +82,21 @@ # noqa: E501 INSTALLED_APPS = [ + # --------------------------------- + # Admin-related apps + # --------------------------------- # Note: admin_tools and its modules must come before django.contrib.admin. 'admin_tools', # https://django-admin-tools.readthedocs.io/en/latest/configuration.html 'admin_tools.menu', # 'admin_tools.theming', # 'admin_tools.dashboard', + 'admin_auto_filters', # https://github.com/farhan0581/django-admin-autocomplete-filter # noqa: E501 + 'admin_honeypot', # https://github.com/dmpayton/django-admin-honeypot + 'django_admin_env_notice', # https://github.com/dizballanze/django-admin-env-notice + 'flat_json_widget', # https://github.com/openwisp/django-flat-json-widget + 'massadmin', # https://github.com/burke-software/django-mass-edit + 'nested_admin', # https://github.com/theatlantic/django-nested-admin + 'tinymce', # https://django-tinymce.readthedocs.io/en/latest/ # --------------------------------- # Django core apps # --------------------------------- @@ -102,8 +112,10 @@ 'django.contrib.staticfiles', 'django.forms', # --------------------------------- - # DRF and auth-related apps + # API- and auth-related apps # --------------------------------- + 'corsheaders', # https://github.com/adamchainz/django-cors-headers + 'graphene_django', # https://github.com/graphql-python/graphene-django 'rest_framework', # https://github.com/encode/django-rest-framework 'rest_framework.authtoken', # https://github.com/iMerica/dj-rest-auth#quick-setup # 'defender', # https://github.com/jazzband/django-defender # TODO @@ -120,49 +132,47 @@ 'allauth.socialaccount.providers.google', 'allauth.socialaccount.providers.twitter', # --------------------------------- + # Model-related apps + # --------------------------------- + 'autoslug', # https://django-autoslug.readthedocs.io/en/latest/ + 'image_cropping', # https://github.com/jonasundderwolf/django-image-cropping + 'polymorphic', # https://django-polymorphic.readthedocs.io/en/stable/ + 'sortedm2m', # https://github.com/jazzband/django-sortedm2m + 'typedmodels', # https://github.com/craigds/django-typed-models + # --------------------------------- # Elasticsearch # --------------------------------- 'django_elasticsearch_dsl', # https://django-elasticsearch-dsl.readthedocs.io/en/latest/quickstart.html # --------------------------------- + # Debugging- and profiling-related apps + # --------------------------------- + 'debug_toolbar', # https://django-debug-toolbar.readthedocs.io/en/latest/ + 'pympler', # https://pympler.readthedocs.io/en/latest/index.html + 'silk', # https://github.com/jazzband/django-silk + # --------------------------------- # Miscellaneous third-party apps # --------------------------------- - 'admin_auto_filters', # https://github.com/farhan0581/django-admin-autocomplete-filter # noqa: E501 - 'autoslug', # https://django-autoslug.readthedocs.io/en/latest/ 'bootstrap_datepicker_plus', # https://django-bootstrap-datepicker-plus.readthedocs.io/en/latest/ # noqa: E501 'cachalot', # https://django-cachalot.readthedocs.io/ 'channels', # https://channels.readthedocs.io/en/latest/index.html - 'corsheaders', # https://github.com/adamchainz/django-cors-headers 'crispy_forms', # https://django-crispy-forms.readthedocs.io/ 'dbbackup', # https://django-dbbackup.readthedocs.io/en/latest/ 'django_celery_beat', # https://github.com/celery/django-celery-beat 'django_celery_results', # https://github.com/celery/django-celery-results 'django_extensions', # https://github.com/django-extensions/django-extensions 'django_replicated', # https://github.com/yandex/django_replicated - 'debug_toolbar', # https://django-debug-toolbar.readthedocs.io/en/latest/ 'django_select2', # https://django-select2.readthedocs.io/en/latest/index.html 'django_social_share', # https://github.com/fcurella/django-social-share 'decouple', # https://github.com/henriquebastos/python-decouple/ 'easy_thumbnails', # https://github.com/jonasundderwolf/django-image-cropping 'extra_views', # https://django-extra-views.readthedocs.io/en/latest/index.html - 'flat_json_widget', # https://github.com/openwisp/django-flat-json-widget - 'graphene_django', # https://github.com/graphql-python/graphene-django 'health_check', # https://github.com/KristianOellegaard/django-health-check - 'health_check.contrib.migrations', 'health_check.contrib.psutil', # disk and memory utilization; requires psutil 'health_check.contrib.redis', - 'image_cropping', # https://github.com/jonasundderwolf/django-image-cropping 'lockdown', # https://github.com/Dunedan/django-lockdown - 'massadmin', # https://github.com/burke-software/django-mass-edit 'meta', # https://django-meta.readthedocs.io/en/latest/ - 'nested_admin', # https://github.com/theatlantic/django-nested-admin - 'polymorphic', # https://django-polymorphic.readthedocs.io/en/stable/ - 'pympler', # https://pympler.readthedocs.io/en/latest/index.html 'sass_processor', # https://github.com/jrief/django-sass-processor - 'sortedm2m', # https://github.com/jazzband/django-sortedm2m - 'tinymce', # https://django-tinymce.readthedocs.io/en/latest/ - 'typedmodels', # https://github.com/craigds/django-typed-models 'watchman', # https://github.com/mwarkentin/django-watchman - 'webpack_loader', # https://github.com/owais/django-webpack-loader # TODO # --------------------------------- # In-project apps # --------------------------------- @@ -193,6 +203,8 @@ 'corsheaders.middleware.CorsMiddleware', # https://docs.djangoproject.com/en/3.1/ref/middleware/#module-django.middleware.security 'django.middleware.security.SecurityMiddleware', + # https://github.com/jazzband/django-silk + 'silk.middleware.SilkyMiddleware', # Update cache: # https://docs.djangoproject.com/en/3.1/topics/cache/#order-of-middleware 'django.middleware.cache.UpdateCacheMiddleware', @@ -239,6 +251,8 @@ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'django_settings_export.settings_export', + # https://github.com/dizballanze/django-admin-env-notice#quickstart + 'django_admin_env_notice.context_processors.from_settings', ], # https://docs.djangoproject.com/en/3.1/ref/templates/api/#loader-types 'loaders': [ @@ -349,7 +363,7 @@ 'loggers': { 'django': { 'handlers': ['console'], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), + 'level': config('DJANGO_LOG_LEVEL', default='INFO'), 'propagate': False, }, }, diff --git a/core/structures/source_file.py b/core/structures/source_file.py index 7c6475dac..179a300a7 100644 --- a/core/structures/source_file.py +++ b/core/structures/source_file.py @@ -9,7 +9,7 @@ from core.utils import files if TYPE_CHECKING: - from apps.sources.models.mixins.textual import TextualMixin + from apps.sources.models.source import Source from apps.sources.models.source_file import SourceFile @@ -34,7 +34,7 @@ def dedupe(self): ) logging.info(f'{source_file.name} -> {duplicated_file_name}') remove(join(settings.MEDIA_ROOT, filename)) - source: 'TextualMixin' + source: 'Source' for source in source_file.sources.all(): source.db_file = designated_source_file source.save() diff --git a/core/templates/_navbar.html b/core/templates/_navbar.html index 0f8e98a5c..7bdccf026 100644 --- a/core/templates/_navbar.html +++ b/core/templates/_navbar.html @@ -23,7 +23,7 @@