diff --git a/django/anthology/settings/__init__.py b/django/anthology/settings/__init__.py
index 4ce272504d0a4337c5d48a84383eb4b6e3a507be..1c0f6451eb0ab5610d8f78540ff0eec9fec2a778 100644
--- a/django/anthology/settings/__init__.py
+++ b/django/anthology/settings/__init__.py
@@ -8,13 +8,15 @@ BASE_DIR = Path(__file__).resolve(strict=True).parents[2]
 # Application definition
 
 INSTALLED_APPS = [
-    "user.apps.UserConfig",
+    "meleager_user.apps.MeleagerUserConfig",
     "web.apps.WebConfig",
     "meleager",
     "guardian",
     "django_extensions",
     "django_select2",
     "rest_framework",
+    "django_filters",
+    "graphene_django",
     "django.contrib.gis",
     "django.contrib.admin",
     "django.contrib.auth",
@@ -121,7 +123,7 @@ USE_TZ = True
 
 STATIC_URL = "/static/"
 
-AUTH_USER_MODEL = "user.User"
+AUTH_USER_MODEL = "meleager_user.User"
 
 CACHES = {
     "default": {
@@ -141,7 +143,8 @@ CACHES = {
 
 REST_FRAMEWORK = {
     "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
-    "PAGE_SIZE": 10,
+    'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
+    "PAGE_SIZE": 50,
 }
 
 LOGGING = {
diff --git a/django/anthology/settings/antholont.py b/django/anthology/settings/antholont.py
index 0f2ca41b8d2f1e810ddcd13e09a309be895accfb..3bacdf454c2714822e0f02182f5b2b15f333b3d3 100644
--- a/django/anthology/settings/antholont.py
+++ b/django/anthology/settings/antholont.py
@@ -6,6 +6,7 @@ SECRET_KEY = "qv!wbd*(@1wx&et0djf#2w_rjww_c$$((u0z@4uck1faf=3!mq"
 # SECURITY WARNING: don't run with debug turned on in production!
 DEBUG = True
 
+USE_X_FORWARDED_HOST = True
 ALLOWED_HOSTS = ["antholont.ecrituresnumeriques.ca", "localhost", "127.0.0.1"]
 
 # The number of validation level permissions in this application.
@@ -14,3 +15,9 @@ ALLOWED_HOSTS = ["antholont.ecrituresnumeriques.ca", "localhost", "127.0.0.1"]
 MAX_VALIDATION_LEVEL = 2
 
 AP_API_IMPORT_DIR = "import_ap_api_data"
+# Configure Graphene schema
+GRAPHENE = {
+    "SCHEMA": "web.schema.schema"
+}
+
+LOGIN_REDIRECT_URL = "/user/profile/"
diff --git a/django/anthology/settings/local.py b/django/anthology/settings/local.py
index 8dd4869361ed1d791bde3d2be8413187a91a6ed4..cd3318f2159aa9de58ad6897a5550722467b4951 100644
--- a/django/anthology/settings/local.py
+++ b/django/anthology/settings/local.py
@@ -1,3 +1,12 @@
 from .antholont import *
 
 DATABASES["default"]["HOST"] = "localhost"
+
+INSTALLED_APPS.append("debug_toolbar")
+MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
+INTERNAL_IPS = [
+    "127.0.0.1",
+    "192.168.0.0",
+]
+
+ROOT_URLCONF="anthology.urls_local"
\ No newline at end of file
diff --git a/django/anthology/urls.py b/django/anthology/urls.py
index 57588784363a85c6b3528ed9d656be643e3c772c..7533305f9d3d92fee648d549c5b49746f5bd0537 100644
--- a/django/anthology/urls.py
+++ b/django/anthology/urls.py
@@ -3,10 +3,17 @@ from django.conf.urls.static import static
 from django.contrib import admin
 from django.urls import include, path
 
+from django.views.decorators.csrf import csrf_exempt
+from graphene_django.views import GraphQLView
+
 urlpatterns = [
-    path("api", include("meleager.api_urls")),
-    path("api-auth", include("rest_framework.urls", namespace="rest_framework")),
+    path("api/", include("meleager.api_urls")),
+    path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
     path("", include("web.urls")),
     path("admin/", admin.site.urls),
     path("select2/", include("django_select2.urls")),
+    path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
+    path("auth/", include("meleager_user.auth_urls")),
+    path("user/", include("meleager_user.user_urls")),
 ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+
diff --git a/django/anthology/urls_local.py b/django/anthology/urls_local.py
new file mode 100644
index 0000000000000000000000000000000000000000..d031bff99ca70821af4a4daa7e12e6b9ba971a2f
--- /dev/null
+++ b/django/anthology/urls_local.py
@@ -0,0 +1,8 @@
+import debug_toolbar
+from django.urls import include, path
+
+from .urls import urlpatterns
+
+urlpatterns.append(
+    path('__debug__/', include(debug_toolbar.urls)),
+)
\ No newline at end of file
diff --git a/django/meleager/admin/passage.py b/django/meleager/admin/passage.py
index 1b324017ed133b9e79418eb025c071d37c03928c..ac0a9511ed50a153fe80bbfdddcbe8375d47c7aa 100644
--- a/django/meleager/admin/passage.py
+++ b/django/meleager/admin/passage.py
@@ -6,8 +6,8 @@ class PassageAdmin(admin.ModelAdmin):
     ordering = ('book__number', 'fragment', 'sub_fragment')
     list_filter = ('book__number', )
     search_fields = ('descriptions__description', )
-    autocomplete_fields = ('descriptions', 'authors', 'keywords', 'city')
-    fields = ('descriptions', 'creator', 'last_editor', 'city', 'authors', 'book', 'fragment', 'sub_fragment', 'keywords')
+    autocomplete_fields = ('descriptions', 'authors', 'keywords', 'cities')
+    fields = ('descriptions', 'creator', 'last_editor', 'cities', 'authors', 'book', 'fragment', 'sub_fragment', 'keywords')
     
     def get_reference(self, obj):
         return f"{obj.book.number}.{obj.fragment}{obj.sub_fragment}"
diff --git a/django/meleager/fixtures/test_data.json b/django/meleager/fixtures/test_data.json
index 43c751c1dc69b128ae4edde0dd53af8269f569c3..6b76762bafc02740402964ade640b427897ca487 100644
--- a/django/meleager/fixtures/test_data.json
+++ b/django/meleager/fixtures/test_data.json
@@ -1 +1,2456 @@
-[{"model": "meleager.work", "pk": 1, "fields": {"created_at": "2020-11-14T02:33:59.818Z", "updated_at": "2020-11-14T02:33:59.818Z", "creator": null, "last_editor": null, "urn": null, "descriptions": [], "alternative_urns": [], "names": []}}, {"model": "meleager.book", "fields": {"created_at": "2020-11-14T02:34:10.052Z", "updated_at": "2020-11-14T02:34:10.052Z", "creator": null, "last_editor": null, "work": 1, "number": 12, "descriptions": []}}, {"model": "meleager.passage", "fields": {"created_at": "2020-11-14T02:34:28.654Z", "updated_at": "2020-11-14T02:34:28.654Z", "creator": null, "last_editor": null, "urn": null, "validation": 0, "city": null, "book": [12, 1], "fragment": 42, "sub_fragment": "", "descriptions": [], "alternative_urns": [], "authors": [], "images": [], "comments": [], "external_references": [], "internal_references": [], "manuscripts": [], "keywords": []}}, {"model": "meleager.passage", "fields": {"created_at": "2020-11-14T02:34:49.041Z", "updated_at": "2020-11-14T02:34:49.041Z", "creator": null, "last_editor": null, "urn": null, "validation": 0, "city": null, "book": [12, 1], "fragment": 12, "sub_fragment": "abc", "descriptions": [], "alternative_urns": [], "authors": [], "images": [], "comments": [], "external_references": [], "internal_references": [], "manuscripts": [], "keywords": []}}]
\ No newline at end of file
+[
+{
+  "model": "meleager.language",
+  "pk": "eng",
+  "fields": {
+    "iso_name": "English",
+    "preferred": true
+  }
+},
+{
+  "model": "meleager.language",
+  "pk": "fra",
+  "fields": {
+    "iso_name": "French",
+    "preferred": true
+  }
+},
+{
+  "model": "meleager.language",
+  "pk": "grc",
+  "fields": {
+    "iso_name": "Ancient Greek (to 1453)",
+    "preferred": true
+  }
+},
+{
+  "model": "meleager.language",
+  "pk": "ita",
+  "fields": {
+    "iso_name": "Italian",
+    "preferred": true
+  }
+},
+{
+  "model": "meleager.language",
+  "pk": "por",
+  "fields": {
+    "iso_name": "Portuguese",
+    "preferred": true
+  }
+},
+{
+  "model": "meleager.language",
+  "pk": "spa",
+  "fields": {
+    "iso_name": "Spanish",
+    "preferred": true
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "user",
+    "model": "user"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "alignment"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "alternativeuri"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "author"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "book"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "city"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "comment"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "description"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "edition"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "editor"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "externalreference"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "keyword"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "keywordcategory"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "language"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "manuscript"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "medium"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "name"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "passage"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "scholium"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "text"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "urn"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "validationlevelname"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "meleager",
+    "model": "work"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "guardian",
+    "model": "groupobjectpermission"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "guardian",
+    "model": "userobjectpermission"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "admin",
+    "model": "logentry"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "auth",
+    "model": "permission"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "auth",
+    "model": "group"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "contenttypes",
+    "model": "contenttype"
+  }
+},
+{
+  "model": "contenttypes.contenttype",
+  "fields": {
+    "app_label": "sessions",
+    "model": "session"
+  }
+},
+{
+  "model": "sessions.session",
+  "pk": "xzmiojspaetc6g1x7znntd70kmsrds84",
+  "fields": {
+    "session_data": ".eJxVjMsOwiAQRf-FtSEwyGNcuu83kIGhUjU0Ke3K-O_apAvd3nPOfYlI21rj1ssSJxYXAeL0uyXKj9J2wHdqt1nmua3LlOSuyIN2OcxcntfD_Tuo1Ou3DsaEgg6Zi1Nj8N6y9oAAJrC1lCzpBJy1wxEAz95jQWWNAeXBmkTi_QG_tDab:1ku3Qa:lfpskGqkwDlraC26L9QdbALuse6Tw6P16RSgcmuUwHE",
+    "expire_date": "2021-01-12T01:03:56.674Z"
+  }
+},
+{
+  "model": "sessions.session",
+  "pk": "zwaydufosp2ebp90fy0b56wxvpuijq99",
+  "fields": {
+    "session_data": ".eJxVjMsOwiAQRf-FtSEwyGNcuu83kIGhUjU0Ke3K-O_apAvd3nPOfYlI21rj1ssSJxYXAeL0uyXKj9J2wHdqt1nmua3LlOSuyIN2OcxcntfD_Tuo1Ou3DsaEgg6Zi1Nj8N6y9oAAJrC1lCzpBJy1wxEAz95jQWWNAeXBmkTi_QG_tDab:1l36Ut:MXlIVzpjmswAOj5ZWlO5iEGQ8v8ZfsXydilNRgtTF98",
+    "expire_date": "2021-02-06T00:09:47.321Z"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add user",
+    "content_type": [
+      "user",
+      "user"
+    ],
+    "codename": "add_user"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change user",
+    "content_type": [
+      "user",
+      "user"
+    ],
+    "codename": "change_user"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete user",
+    "content_type": [
+      "user",
+      "user"
+    ],
+    "codename": "delete_user"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view user",
+    "content_type": [
+      "user",
+      "user"
+    ],
+    "codename": "view_user"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add alignment",
+    "content_type": [
+      "meleager",
+      "alignment"
+    ],
+    "codename": "add_alignment"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change alignment",
+    "content_type": [
+      "meleager",
+      "alignment"
+    ],
+    "codename": "change_alignment"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete alignment",
+    "content_type": [
+      "meleager",
+      "alignment"
+    ],
+    "codename": "delete_alignment"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view alignment",
+    "content_type": [
+      "meleager",
+      "alignment"
+    ],
+    "codename": "view_alignment"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "The permission to set validation level 0, see ValidationLevelName.validation_level = 0 for the validation level name.",
+    "content_type": [
+      "meleager",
+      "alignment"
+    ],
+    "codename": "set_validation_level_0"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "The permission to set validation level 1, see ValidationLevelName.validation_level = 1 for the validation level name.",
+    "content_type": [
+      "meleager",
+      "alignment"
+    ],
+    "codename": "set_validation_level_1"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add alternative uri",
+    "content_type": [
+      "meleager",
+      "alternativeuri"
+    ],
+    "codename": "add_alternativeuri"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change alternative uri",
+    "content_type": [
+      "meleager",
+      "alternativeuri"
+    ],
+    "codename": "change_alternativeuri"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete alternative uri",
+    "content_type": [
+      "meleager",
+      "alternativeuri"
+    ],
+    "codename": "delete_alternativeuri"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view alternative uri",
+    "content_type": [
+      "meleager",
+      "alternativeuri"
+    ],
+    "codename": "view_alternativeuri"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add author",
+    "content_type": [
+      "meleager",
+      "author"
+    ],
+    "codename": "add_author"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change author",
+    "content_type": [
+      "meleager",
+      "author"
+    ],
+    "codename": "change_author"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete author",
+    "content_type": [
+      "meleager",
+      "author"
+    ],
+    "codename": "delete_author"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view author",
+    "content_type": [
+      "meleager",
+      "author"
+    ],
+    "codename": "view_author"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add book",
+    "content_type": [
+      "meleager",
+      "book"
+    ],
+    "codename": "add_book"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change book",
+    "content_type": [
+      "meleager",
+      "book"
+    ],
+    "codename": "change_book"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete book",
+    "content_type": [
+      "meleager",
+      "book"
+    ],
+    "codename": "delete_book"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view book",
+    "content_type": [
+      "meleager",
+      "book"
+    ],
+    "codename": "view_book"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add city",
+    "content_type": [
+      "meleager",
+      "city"
+    ],
+    "codename": "add_city"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change city",
+    "content_type": [
+      "meleager",
+      "city"
+    ],
+    "codename": "change_city"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete city",
+    "content_type": [
+      "meleager",
+      "city"
+    ],
+    "codename": "delete_city"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view city",
+    "content_type": [
+      "meleager",
+      "city"
+    ],
+    "codename": "view_city"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add comment",
+    "content_type": [
+      "meleager",
+      "comment"
+    ],
+    "codename": "add_comment"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change comment",
+    "content_type": [
+      "meleager",
+      "comment"
+    ],
+    "codename": "change_comment"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete comment",
+    "content_type": [
+      "meleager",
+      "comment"
+    ],
+    "codename": "delete_comment"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view comment",
+    "content_type": [
+      "meleager",
+      "comment"
+    ],
+    "codename": "view_comment"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add description",
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "codename": "add_description"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change description",
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "codename": "change_description"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete description",
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "codename": "delete_description"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view description",
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "codename": "view_description"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add edition",
+    "content_type": [
+      "meleager",
+      "edition"
+    ],
+    "codename": "add_edition"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change edition",
+    "content_type": [
+      "meleager",
+      "edition"
+    ],
+    "codename": "change_edition"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete edition",
+    "content_type": [
+      "meleager",
+      "edition"
+    ],
+    "codename": "delete_edition"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view edition",
+    "content_type": [
+      "meleager",
+      "edition"
+    ],
+    "codename": "view_edition"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add editor",
+    "content_type": [
+      "meleager",
+      "editor"
+    ],
+    "codename": "add_editor"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change editor",
+    "content_type": [
+      "meleager",
+      "editor"
+    ],
+    "codename": "change_editor"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete editor",
+    "content_type": [
+      "meleager",
+      "editor"
+    ],
+    "codename": "delete_editor"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view editor",
+    "content_type": [
+      "meleager",
+      "editor"
+    ],
+    "codename": "view_editor"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add external reference",
+    "content_type": [
+      "meleager",
+      "externalreference"
+    ],
+    "codename": "add_externalreference"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change external reference",
+    "content_type": [
+      "meleager",
+      "externalreference"
+    ],
+    "codename": "change_externalreference"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete external reference",
+    "content_type": [
+      "meleager",
+      "externalreference"
+    ],
+    "codename": "delete_externalreference"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view external reference",
+    "content_type": [
+      "meleager",
+      "externalreference"
+    ],
+    "codename": "view_externalreference"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add keyword",
+    "content_type": [
+      "meleager",
+      "keyword"
+    ],
+    "codename": "add_keyword"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change keyword",
+    "content_type": [
+      "meleager",
+      "keyword"
+    ],
+    "codename": "change_keyword"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete keyword",
+    "content_type": [
+      "meleager",
+      "keyword"
+    ],
+    "codename": "delete_keyword"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view keyword",
+    "content_type": [
+      "meleager",
+      "keyword"
+    ],
+    "codename": "view_keyword"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add keyword category",
+    "content_type": [
+      "meleager",
+      "keywordcategory"
+    ],
+    "codename": "add_keywordcategory"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change keyword category",
+    "content_type": [
+      "meleager",
+      "keywordcategory"
+    ],
+    "codename": "change_keywordcategory"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete keyword category",
+    "content_type": [
+      "meleager",
+      "keywordcategory"
+    ],
+    "codename": "delete_keywordcategory"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view keyword category",
+    "content_type": [
+      "meleager",
+      "keywordcategory"
+    ],
+    "codename": "view_keywordcategory"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add language",
+    "content_type": [
+      "meleager",
+      "language"
+    ],
+    "codename": "add_language"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change language",
+    "content_type": [
+      "meleager",
+      "language"
+    ],
+    "codename": "change_language"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete language",
+    "content_type": [
+      "meleager",
+      "language"
+    ],
+    "codename": "delete_language"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view language",
+    "content_type": [
+      "meleager",
+      "language"
+    ],
+    "codename": "view_language"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add manuscript",
+    "content_type": [
+      "meleager",
+      "manuscript"
+    ],
+    "codename": "add_manuscript"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change manuscript",
+    "content_type": [
+      "meleager",
+      "manuscript"
+    ],
+    "codename": "change_manuscript"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete manuscript",
+    "content_type": [
+      "meleager",
+      "manuscript"
+    ],
+    "codename": "delete_manuscript"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view manuscript",
+    "content_type": [
+      "meleager",
+      "manuscript"
+    ],
+    "codename": "view_manuscript"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add medium",
+    "content_type": [
+      "meleager",
+      "medium"
+    ],
+    "codename": "add_medium"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change medium",
+    "content_type": [
+      "meleager",
+      "medium"
+    ],
+    "codename": "change_medium"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete medium",
+    "content_type": [
+      "meleager",
+      "medium"
+    ],
+    "codename": "delete_medium"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view medium",
+    "content_type": [
+      "meleager",
+      "medium"
+    ],
+    "codename": "view_medium"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add name",
+    "content_type": [
+      "meleager",
+      "name"
+    ],
+    "codename": "add_name"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change name",
+    "content_type": [
+      "meleager",
+      "name"
+    ],
+    "codename": "change_name"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete name",
+    "content_type": [
+      "meleager",
+      "name"
+    ],
+    "codename": "delete_name"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view name",
+    "content_type": [
+      "meleager",
+      "name"
+    ],
+    "codename": "view_name"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add passage",
+    "content_type": [
+      "meleager",
+      "passage"
+    ],
+    "codename": "add_passage"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change passage",
+    "content_type": [
+      "meleager",
+      "passage"
+    ],
+    "codename": "change_passage"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete passage",
+    "content_type": [
+      "meleager",
+      "passage"
+    ],
+    "codename": "delete_passage"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view passage",
+    "content_type": [
+      "meleager",
+      "passage"
+    ],
+    "codename": "view_passage"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add scholium",
+    "content_type": [
+      "meleager",
+      "scholium"
+    ],
+    "codename": "add_scholium"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change scholium",
+    "content_type": [
+      "meleager",
+      "scholium"
+    ],
+    "codename": "change_scholium"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete scholium",
+    "content_type": [
+      "meleager",
+      "scholium"
+    ],
+    "codename": "delete_scholium"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view scholium",
+    "content_type": [
+      "meleager",
+      "scholium"
+    ],
+    "codename": "view_scholium"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add text",
+    "content_type": [
+      "meleager",
+      "text"
+    ],
+    "codename": "add_text"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change text",
+    "content_type": [
+      "meleager",
+      "text"
+    ],
+    "codename": "change_text"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete text",
+    "content_type": [
+      "meleager",
+      "text"
+    ],
+    "codename": "delete_text"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view text",
+    "content_type": [
+      "meleager",
+      "text"
+    ],
+    "codename": "view_text"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add urn",
+    "content_type": [
+      "meleager",
+      "urn"
+    ],
+    "codename": "add_urn"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change urn",
+    "content_type": [
+      "meleager",
+      "urn"
+    ],
+    "codename": "change_urn"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete urn",
+    "content_type": [
+      "meleager",
+      "urn"
+    ],
+    "codename": "delete_urn"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view urn",
+    "content_type": [
+      "meleager",
+      "urn"
+    ],
+    "codename": "view_urn"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add validation level name",
+    "content_type": [
+      "meleager",
+      "validationlevelname"
+    ],
+    "codename": "add_validationlevelname"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change validation level name",
+    "content_type": [
+      "meleager",
+      "validationlevelname"
+    ],
+    "codename": "change_validationlevelname"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete validation level name",
+    "content_type": [
+      "meleager",
+      "validationlevelname"
+    ],
+    "codename": "delete_validationlevelname"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view validation level name",
+    "content_type": [
+      "meleager",
+      "validationlevelname"
+    ],
+    "codename": "view_validationlevelname"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add work",
+    "content_type": [
+      "meleager",
+      "work"
+    ],
+    "codename": "add_work"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change work",
+    "content_type": [
+      "meleager",
+      "work"
+    ],
+    "codename": "change_work"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete work",
+    "content_type": [
+      "meleager",
+      "work"
+    ],
+    "codename": "delete_work"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view work",
+    "content_type": [
+      "meleager",
+      "work"
+    ],
+    "codename": "view_work"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add group object permission",
+    "content_type": [
+      "guardian",
+      "groupobjectpermission"
+    ],
+    "codename": "add_groupobjectpermission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change group object permission",
+    "content_type": [
+      "guardian",
+      "groupobjectpermission"
+    ],
+    "codename": "change_groupobjectpermission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete group object permission",
+    "content_type": [
+      "guardian",
+      "groupobjectpermission"
+    ],
+    "codename": "delete_groupobjectpermission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view group object permission",
+    "content_type": [
+      "guardian",
+      "groupobjectpermission"
+    ],
+    "codename": "view_groupobjectpermission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add user object permission",
+    "content_type": [
+      "guardian",
+      "userobjectpermission"
+    ],
+    "codename": "add_userobjectpermission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change user object permission",
+    "content_type": [
+      "guardian",
+      "userobjectpermission"
+    ],
+    "codename": "change_userobjectpermission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete user object permission",
+    "content_type": [
+      "guardian",
+      "userobjectpermission"
+    ],
+    "codename": "delete_userobjectpermission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view user object permission",
+    "content_type": [
+      "guardian",
+      "userobjectpermission"
+    ],
+    "codename": "view_userobjectpermission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add log entry",
+    "content_type": [
+      "admin",
+      "logentry"
+    ],
+    "codename": "add_logentry"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change log entry",
+    "content_type": [
+      "admin",
+      "logentry"
+    ],
+    "codename": "change_logentry"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete log entry",
+    "content_type": [
+      "admin",
+      "logentry"
+    ],
+    "codename": "delete_logentry"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view log entry",
+    "content_type": [
+      "admin",
+      "logentry"
+    ],
+    "codename": "view_logentry"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add permission",
+    "content_type": [
+      "auth",
+      "permission"
+    ],
+    "codename": "add_permission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change permission",
+    "content_type": [
+      "auth",
+      "permission"
+    ],
+    "codename": "change_permission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete permission",
+    "content_type": [
+      "auth",
+      "permission"
+    ],
+    "codename": "delete_permission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view permission",
+    "content_type": [
+      "auth",
+      "permission"
+    ],
+    "codename": "view_permission"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add group",
+    "content_type": [
+      "auth",
+      "group"
+    ],
+    "codename": "add_group"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change group",
+    "content_type": [
+      "auth",
+      "group"
+    ],
+    "codename": "change_group"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete group",
+    "content_type": [
+      "auth",
+      "group"
+    ],
+    "codename": "delete_group"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view group",
+    "content_type": [
+      "auth",
+      "group"
+    ],
+    "codename": "view_group"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add content type",
+    "content_type": [
+      "contenttypes",
+      "contenttype"
+    ],
+    "codename": "add_contenttype"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change content type",
+    "content_type": [
+      "contenttypes",
+      "contenttype"
+    ],
+    "codename": "change_contenttype"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete content type",
+    "content_type": [
+      "contenttypes",
+      "contenttype"
+    ],
+    "codename": "delete_contenttype"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view content type",
+    "content_type": [
+      "contenttypes",
+      "contenttype"
+    ],
+    "codename": "view_contenttype"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can add session",
+    "content_type": [
+      "sessions",
+      "session"
+    ],
+    "codename": "add_session"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can change session",
+    "content_type": [
+      "sessions",
+      "session"
+    ],
+    "codename": "change_session"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can delete session",
+    "content_type": [
+      "sessions",
+      "session"
+    ],
+    "codename": "delete_session"
+  }
+},
+{
+  "model": "auth.permission",
+  "fields": {
+    "name": "Can view session",
+    "content_type": [
+      "sessions",
+      "session"
+    ],
+    "codename": "view_session"
+  }
+},
+{
+  "model": "meleager_user.user",
+  "fields": {
+    "password": "!cftze7QXrzidvow2x53dqyGZFqBHkZg63vEFGzJw",
+    "last_login": null,
+    "is_superuser": false,
+    "username": "AnonymousUser",
+    "first_name": "",
+    "last_name": "",
+    "email": "",
+    "is_staff": false,
+    "is_active": true,
+    "date_joined": "2020-11-12T06:14:06.173Z",
+    "institution": "",
+    "groups": [],
+    "user_permissions": []
+  }
+},
+{
+  "model": "meleager_user.user",
+  "fields": {
+    "password": "pbkdf2_sha256$216000$itKY4GPhYWHR$d1pd9QDaZZosNEzNcul6UCE/M3BtvK3DhRQiO1y6Ga4=",
+    "last_login": "2021-01-23T00:09:47.317Z",
+    "is_superuser": true,
+    "username": "test_admin",
+    "first_name": "",
+    "last_name": "",
+    "email": "admin@test.com",
+    "is_staff": true,
+    "is_active": true,
+    "date_joined": "2020-11-12T06:59:06.702Z",
+    "institution": "",
+    "groups": [],
+    "user_permissions": []
+  }
+},
+{
+  "model": "meleager_user.user",
+  "fields": {
+    "password": "pbkdf2_sha256$216000$NWmmXTMZTxoW$afq3dy1KRUu0nTeoKihjhB2kaa7BQPxON+D+rvLXkGQ=",
+    "last_login": null,
+    "is_superuser": false,
+    "username": "meleager",
+    "first_name": "",
+    "last_name": "",
+    "email": "",
+    "is_staff": false,
+    "is_active": true,
+    "date_joined": "2020-12-29T01:07:01.535Z",
+    "institution": "",
+    "groups": [],
+    "user_permissions": []
+  }
+},
+{
+  "model": "meleager.author",
+  "pk": 1,
+  "fields": {
+    "created_at": "2020-12-29T01:13:53Z",
+    "updated_at": "2020-12-29T01:13:53Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "urn": null,
+    "city_born": null,
+    "born_range_year_date": "{\"bounds\": \"[)\", \"lower\": \"-42\", \"upper\": null}",
+    "city_died": null,
+    "died_range_year_date": "{\"bounds\": \"[)\", \"lower\": \"42\", \"upper\": null}",
+    "descriptions": [
+      5
+    ],
+    "alternative_urns": [],
+    "names": [
+      1
+    ],
+    "images": []
+  }
+},
+{
+  "model": "meleager.city",
+  "pk": 1,
+  "fields": {
+    "created_at": "2020-12-29T01:14:36Z",
+    "updated_at": "2020-12-29T01:14:36Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "location": "SRID=4326;POINT (-156.5457497324263 -35.17381082366806)",
+    "descriptions": [
+      6
+    ],
+    "names": [
+      2
+    ]
+  }
+},
+{
+  "model": "meleager.comment",
+  "pk": 1,
+  "fields": {
+    "created_at": "2020-12-29T02:23:29Z",
+    "updated_at": "2020-12-29T02:23:29Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "comment_type": "user_note",
+    "comment_title": "COMMENT TITLE",
+    "descriptions": [
+      8
+    ],
+    "images": []
+  }
+},
+{
+  "model": "meleager.description",
+  "pk": 1,
+  "fields": {
+    "created_at": "2020-12-29T01:07:32Z",
+    "updated_at": "2020-12-29T01:07:32Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "description": "WORK DESC FR",
+    "language": "fra"
+  }
+},
+{
+  "model": "meleager.description",
+  "pk": 2,
+  "fields": {
+    "created_at": "2020-12-29T01:08:38Z",
+    "updated_at": "2020-12-29T01:08:38Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "description": "WORK DESC EN",
+    "language": "eng"
+  }
+},
+{
+  "model": "meleager.description",
+  "pk": 3,
+  "fields": {
+    "created_at": "2020-12-29T01:09:20Z",
+    "updated_at": "2020-12-29T01:09:20Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "description": "BOOK DESC FR",
+    "language": "fra"
+  }
+},
+{
+  "model": "meleager.description",
+  "pk": 4,
+  "fields": {
+    "created_at": "2020-12-29T01:09:59Z",
+    "updated_at": "2020-12-29T01:09:59Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "description": "PASSAGE DESC FR",
+    "language": "fra"
+  }
+},
+{
+  "model": "meleager.description",
+  "pk": 5,
+  "fields": {
+    "created_at": "2020-12-29T01:11:18Z",
+    "updated_at": "2020-12-29T01:11:18Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "description": "AUTHOR DESC FR",
+    "language": "fra"
+  }
+},
+{
+  "model": "meleager.description",
+  "pk": 6,
+  "fields": {
+    "created_at": "2020-12-29T01:15:10Z",
+    "updated_at": "2020-12-29T01:15:10Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "description": "CITY DESC EN",
+    "language": "eng"
+  }
+},
+{
+  "model": "meleager.description",
+  "pk": 7,
+  "fields": {
+    "created_at": "2020-12-29T01:17:24Z",
+    "updated_at": "2020-12-29T01:17:24Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "description": "PASSAGE DESC POR",
+    "language": "por"
+  }
+},
+{
+  "model": "meleager.description",
+  "pk": 8,
+  "fields": {
+    "created_at": "2020-12-29T02:23:37Z",
+    "updated_at": "2020-12-29T02:23:37Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "description": "COMMENT DESC FR",
+    "language": "fra"
+  }
+},
+{
+  "model": "meleager.description",
+  "pk": 9,
+  "fields": {
+    "created_at": "2021-01-23T00:26:30Z",
+    "updated_at": "2021-01-23T00:26:30Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "description": "Scholium description",
+    "language": "eng"
+  }
+},
+{
+  "model": "meleager.name",
+  "pk": 1,
+  "fields": {
+    "created_at": "2020-12-29T01:10:55Z",
+    "updated_at": "2020-12-29T01:10:55Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "name": "AUTHOR FR",
+    "language": "fra"
+  }
+},
+{
+  "model": "meleager.name",
+  "pk": 2,
+  "fields": {
+    "created_at": "2020-12-29T01:15:50Z",
+    "updated_at": "2020-12-29T01:15:50Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "name": "CITY SPA",
+    "language": "spa"
+  }
+},
+{
+  "model": "meleager.work",
+  "pk": 1,
+  "fields": {
+    "created_at": "2019-04-01T10:00:00Z",
+    "updated_at": "2020-04-01T20:00:00Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "urn": null,
+    "alternative_urns": [],
+    "descriptions": [
+      1,
+      2
+    ]
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 1,
+  "fields": {
+    "action_time": "2020-12-29T01:07:01.652Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "user",
+      "user"
+    ],
+    "object_id": "3",
+    "object_repr": "meleager",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 2,
+  "fields": {
+    "action_time": "2020-12-29T01:08:36.295Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "object_id": "1",
+    "object_repr": "WORK DESC FR [Lang: fra]",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 3,
+  "fields": {
+    "action_time": "2020-12-29T01:08:54.672Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "object_id": "2",
+    "object_repr": "WORK DESC EN [Lang: eng]",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 4,
+  "fields": {
+    "action_time": "2020-12-29T01:08:57.239Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "work"
+    ],
+    "object_id": "1",
+    "object_repr": "WORK DESC FR",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 5,
+  "fields": {
+    "action_time": "2020-12-29T01:09:42.035Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "object_id": "3",
+    "object_repr": "BOOK DESC FR [Lang: fra]",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 6,
+  "fields": {
+    "action_time": "2020-12-29T01:09:46.567Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "book"
+    ],
+    "object_id": "1",
+    "object_repr": "Book 42",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 7,
+  "fields": {
+    "action_time": "2020-12-29T01:10:17.872Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "object_id": "4",
+    "object_repr": "PASSAGE DESC FR [Lang: fra]",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 8,
+  "fields": {
+    "action_time": "2020-12-29T01:11:09.284Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "name"
+    ],
+    "object_id": "1",
+    "object_repr": "AUTHOR FR (Lang: fra)",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 9,
+  "fields": {
+    "action_time": "2020-12-29T01:11:33.053Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "object_id": "5",
+    "object_repr": "AUTHOR DESC FR [Lang: fra]",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 10,
+  "fields": {
+    "action_time": "2020-12-29T01:14:06.834Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "author"
+    ],
+    "object_id": "1",
+    "object_repr": "AUTHOR FR (1)",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 11,
+  "fields": {
+    "action_time": "2020-12-29T01:14:10.148Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "passage"
+    ],
+    "object_id": "1",
+    "object_repr": "Passage 42.12",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 12,
+  "fields": {
+    "action_time": "2020-12-29T01:14:31.005Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "author"
+    ],
+    "object_id": "1",
+    "object_repr": "AUTHOR FR (1)",
+    "action_flag": 2,
+    "change_message": "[{\"changed\": {\"fields\": [\"Born range year date\", \"Died range year date\"]}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 13,
+  "fields": {
+    "action_time": "2020-12-29T01:15:23.152Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "object_id": "6",
+    "object_repr": "CITY DESC EN [Lang: eng]",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 14,
+  "fields": {
+    "action_time": "2020-12-29T01:16:13.652Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "name"
+    ],
+    "object_id": "2",
+    "object_repr": "CITY SPA (Lang: spa)",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 15,
+  "fields": {
+    "action_time": "2020-12-29T01:16:21.653Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "city"
+    ],
+    "object_id": "1",
+    "object_repr": "CITY SPA",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 16,
+  "fields": {
+    "action_time": "2020-12-29T01:17:54.345Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "object_id": "7",
+    "object_repr": "PASSAGE DESC POR [Lang: por]",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 17,
+  "fields": {
+    "action_time": "2020-12-29T01:33:34.786Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "passage"
+    ],
+    "object_id": "2",
+    "object_repr": "Passage 42.69abc",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 18,
+  "fields": {
+    "action_time": "2020-12-29T02:23:49.377Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "object_id": "8",
+    "object_repr": "COMMENT DESC FR [Lang: fra]",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 19,
+  "fields": {
+    "action_time": "2020-12-29T02:23:54.373Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "comment"
+    ],
+    "object_id": "1",
+    "object_repr": "Comment COMMENT DESC FR",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 20,
+  "fields": {
+    "action_time": "2021-01-23T00:26:44.868Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "description"
+    ],
+    "object_id": "9",
+    "object_repr": "Scholium description [Lang: eng]",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 21,
+  "fields": {
+    "action_time": "2021-01-23T00:28:09.474Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "scholium"
+    ],
+    "object_id": "1",
+    "object_repr": "Scholium 42.12.666",
+    "action_flag": 1,
+    "change_message": "[{\"added\": {}}]"
+  }
+},
+{
+  "model": "admin.logentry",
+  "pk": 22,
+  "fields": {
+    "action_time": "2021-01-25T06:21:29.859Z",
+    "user": [
+      "test_admin"
+    ],
+    "content_type": [
+      "meleager",
+      "passage"
+    ],
+    "object_id": "2",
+    "object_repr": "Passage 42.69abc",
+    "action_flag": 2,
+    "change_message": "[{\"changed\": {\"fields\": [\"Cities\"]}}]"
+  }
+},
+{
+  "model": "meleager.book",
+  "fields": {
+    "created_at": "2020-12-29T01:09:05Z",
+    "updated_at": "2020-12-29T01:09:05Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "work": 1,
+    "number": 42,
+    "descriptions": [
+      3
+    ]
+  }
+},
+{
+  "model": "meleager.passage",
+  "fields": {
+    "created_at": "2020-12-29T01:14:10.123Z",
+    "updated_at": "2020-12-29T01:14:10.123Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "urn": null,
+    "validation": 0,
+    "book": [
+      42,
+      1
+    ],
+    "fragment": 12,
+    "sub_fragment": "",
+    "descriptions": [
+      4
+    ],
+    "alternative_urns": [],
+    "authors": [
+      1
+    ],
+    "cities": [],
+    "images": [],
+    "comments": [],
+    "alternative_uris": [],
+    "external_references": [],
+    "internal_references": [],
+    "manuscripts": [],
+    "keywords": []
+  }
+},
+{
+  "model": "meleager.passage",
+  "fields": {
+    "created_at": "2020-12-29T01:33:34.720Z",
+    "updated_at": "2020-12-29T01:33:34.720Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "urn": null,
+    "validation": 0,
+    "book": [
+      42,
+      1
+    ],
+    "fragment": 69,
+    "sub_fragment": "abc",
+    "descriptions": [
+      7
+    ],
+    "alternative_urns": [],
+    "authors": [
+      1
+    ],
+    "cities": [
+      1
+    ],
+    "images": [],
+    "comments": [],
+    "alternative_uris": [],
+    "external_references": [],
+    "internal_references": [],
+    "manuscripts": [],
+    "keywords": []
+  }
+},
+{
+  "model": "meleager.scholium",
+  "pk": 1,
+  "fields": {
+    "created_at": "2021-01-23T00:26:04Z",
+    "updated_at": "2021-01-23T00:26:04Z",
+    "creator": [
+      "meleager"
+    ],
+    "last_editor": [
+      "meleager"
+    ],
+    "urn": null,
+    "validation": 0,
+    "number": 666,
+    "passage": [
+      12,
+      "",
+      42,
+      1
+    ],
+    "city": null,
+    "descriptions": [
+      9
+    ],
+    "alternative_urns": [],
+    "manuscripts": [],
+    "images": [],
+    "comments": [],
+    "keywords": []
+  }
+}
+]
diff --git a/django/meleager/management/commands/ap_api_manager/managers/books.py b/django/meleager/management/commands/ap_api_manager/managers/books.py
index 083a57aeea164651b5d51ba1034463f31c318e54..f7b15a030a00de7de5aeb397577d80be3c28165c 100644
--- a/django/meleager/management/commands/ap_api_manager/managers/books.py
+++ b/django/meleager/management/commands/ap_api_manager/managers/books.py
@@ -7,12 +7,14 @@ class BookFromApi:
     def __new__(cls, work, number):
         if (work, number) not in cls._books:
             # Fetch the Book object, create it if it does not exist - let's consider numbers are kind of unique
-            book_obj = Book.objects.filter(
+            books = Book.objects.filter(
                 work__descriptions__description=work, number=number
             )
-            if not book_obj:
+            if not books.count():
                 work_obj_pk = Work.objects.get(descriptions__description=work).pk
                 book_obj = Book.objects.create(work_id=work_obj_pk, number=number)
+            else:
+                book_obj = books.first()
 
             cls._books[(work, number)] = book_obj
         else:
diff --git a/django/meleager/management/commands/ap_api_manager/managers/passages.py b/django/meleager/management/commands/ap_api_manager/managers/passages.py
index 2b0ebbb050e0b3a717de73f616be22499c90fcb3..f01af44e6a571437866f1cfd6c3cc60b440768e7 100644
--- a/django/meleager/management/commands/ap_api_manager/managers/passages.py
+++ b/django/meleager/management/commands/ap_api_manager/managers/passages.py
@@ -84,18 +84,33 @@ class PassageFromApi:
 
         # CREATE THE ALIGNMENTS
         for item in passage_json["alignements"]:
+            text_1 = None
+            text_2 = None
+
+            try:
+                text_1 = TextFromApi.get_by_id(item["source"])
+            except KeyError:
+                logger.info(
+                    "Warning - cannot find text ID %s in alignment for entity ID %s", item["source"], passage_json["id_entity"]
+                )
+
             try:
+                text_2 = TextFromApi.get_by_id(item["target"])
+            except KeyError:
+                logger.info(
+                    "Warning - cannot find text ID %s in alignment for entity ID %s", item["target"], passage_json["id_entity"]
+                )
+
+            if text_1 or text_2:
                 align_obj = Alignment.objects.create(
-                    text_1=TextFromApi.get_by_id(item["source"]),
-                    text_2=TextFromApi.get_by_id(item["target"]),
+                    text_1=text_1, text_2=text_2,
                     alignment_data=item["json"],
                 )
                 BaseManager.update_obj_user_dates_from_json(align_obj, item)
-            except KeyError:
+            else:
                 logger.info(
-                    "Alignment skipped - Entity ID %s", passage_json["id_entity"]
+                    "Both texts ID (%s, %s) from alignment do not exist", item["source"], item["target"]
                 )
-                continue
 
         # CREATE THE IMAGES
         for image_json in passage_json["images"]:
diff --git a/django/meleager/management/commands/ap_api_manager/managers/scholies.py b/django/meleager/management/commands/ap_api_manager/managers/scholies.py
index 41df7ab1ead46e861d6e7d24f35ab3bf4ac9bd16..1a34f692b97f8d88fd51c56efce7c22793eec2f4 100644
--- a/django/meleager/management/commands/ap_api_manager/managers/scholies.py
+++ b/django/meleager/management/commands/ap_api_manager/managers/scholies.py
@@ -7,6 +7,7 @@ from ..helpers import to_date
 from .data_manager import DataManager
 from .media import ImageFromApi, ManuscriptFromApi
 from .users import UserFromApi
+from .texts import ScholiumTextFromApi
 
 logger = logging.getLogger(__name__)
 
@@ -16,7 +17,7 @@ class ScholiumFromApi:
     def __new__(cls, id_scholie, passage_pk):
         if id_scholie not in cls._scholies:
             scholium_obj = cls._create_scholium(id_scholie, passage_pk)
-            logger.info("NEW | Scholium (AP API %s)", id_scholie)
+            logger.info("NEW | Scholium PK %s (AP API %s)", scholium_obj.pk, id_scholie)
         else:
             scholium_obj = cls._scholies[id_scholie]
 
@@ -35,6 +36,10 @@ class ScholiumFromApi:
             updated_at=to_date(json_data["updatedAt"]),
         )
 
+        for text in json_data["versions"]:
+            text_obj = ScholiumTextFromApi(text)
+            scholium_obj.texts.add(text_obj)
+
         for image in json_data["images"]:
             image_obj = ManuscriptFromApi(key=None, json_data=image, key_name="id_image")
             scholium_obj.manuscripts.add(image_obj)
diff --git a/django/meleager/management/commands/ap_api_manager/managers/texts.py b/django/meleager/management/commands/ap_api_manager/managers/texts.py
index 8fe8935bd92f04dd39b036b00c99b7ad055263fb..1a8011a514540fae632e96d306c5ff0a5db01b9f 100644
--- a/django/meleager/management/commands/ap_api_manager/managers/texts.py
+++ b/django/meleager/management/commands/ap_api_manager/managers/texts.py
@@ -1,22 +1,23 @@
 import logging
 
-from meleager.models import Text
+from meleager.models import Text, Work, Description, Edition
 
 from ..constants import LANG_API2CODE
 from .editions import EditionFromApi
 
 logger = logging.getLogger(__name__)
 
-
-class TextFromApi:
-    _texts = dict()
+class AbstractTextFromApi:
+    # To be overriden by children classes
+    json_key = None
+    text_key = None
 
     def __new__(cls, json_data, work_obj_pk):
-        if json_data["id_entity_version"] not in cls._texts:
+        if json_data[cls.json_key] not in cls._texts:
             text_obj = cls._create_text(json_data, work_obj_pk)
-            cls._texts[json_data["id_entity_version"]] = text_obj
+            cls._texts[json_data[cls.json_key]] = text_obj
         else:
-            text_obj = cls._texts[json_data["id_entity_version"]]
+            text_obj = cls._texts[json_data[cls.json_key]]
 
         return text_obj
 
@@ -25,12 +26,12 @@ class TextFromApi:
         try:
             return cls._texts[text_id]
         except KeyError as exc:
-            logging.warn("!!! Error: cannot find text %s for alignment.", text_id)
+            logging.warn("!!! Error: cannot find text %s", text_id)
             raise KeyError from exc
 
     @classmethod
     def _create_text(cls, json_data, work_obj_pk):
-        text = json_data["text_translated"]
+        text = json_data[cls.text_key]
         lang_code = LANG_API2CODE[json_data["id_language"]]
 
         edition_obj = EditionFromApi(
@@ -44,3 +45,56 @@ class TextFromApi:
         # TODO: add edition information
         logger.info("NEW | Text (%s) %s", lang_code.upper(), text[0:25])
         return text_obj
+
+class TextFromApi(AbstractTextFromApi):
+    json_key = "id_entity_version"
+    text_key = "text_translated"
+    # This is very poor (#116)
+    _texts = dict()
+
+class ScholiumTextFromApi(AbstractTextFromApi):
+    json_key = "id_scholie_version"
+    text_key = "text"
+    # This is very poor (#116)
+    _texts = dict()
+
+    def __new__(cls, json_data):
+        """ We don't use work_obj_pk here because scholia have a set work / edition (see issue #116) """
+        return super().__new__(cls, json_data, None)
+
+    @classmethod
+    def _create_text(cls, json_data, work_obj_pk=None):
+        """ We don't use work_obj_pk here because scholia have a set work / edition (see issue #116) """
+
+        text = json_data[cls.text_key]
+        lang_code = LANG_API2CODE[json_data["id_language"]]
+
+        desc, _ = Description.objects.get_or_create(description="Work Scholia", language_id="eng")
+
+        if not desc.works.count():
+            work = Work.objects.create()
+            work.descriptions.add(desc)
+        else:
+            work = desc.works.first()
+
+        if not work.editions.count():
+            desc, _ = Description.objects.get_or_create(description="Edition Scholia", language_id="eng")
+            editions = Edition.objects.filter(descriptions=desc).count()
+            if not editions:
+                edition, _ = Edition.objects.get_or_create(
+                    work=work,
+                    edition_type=Edition.EditionType.SCHOLIA,
+                )
+                edition.descriptions.add(desc)
+            else:
+                edition = editions.first()
+        else:
+            edition = work.editions.first()
+
+        text_obj = Text.objects.create(
+            text=text, language_id=lang_code, edition=edition
+        )
+
+        # TODO: add edition information
+        logger.info("NEW | Text (%s) %s", lang_code.upper(), text[0:25])
+        return text_obj
\ No newline at end of file
diff --git a/django/meleager/management/commands/ap_api_manager/managers/users.py b/django/meleager/management/commands/ap_api_manager/managers/users.py
index ef69e0f545b3b9aa40a26a0de323814ccf5f823b..fc1fd081673e2a36fad10629c0162cf5b2a37bf1 100644
--- a/django/meleager/management/commands/ap_api_manager/managers/users.py
+++ b/django/meleager/management/commands/ap_api_manager/managers/users.py
@@ -1,6 +1,6 @@
 import logging
 
-from user.models import User
+from meleager_user.models import User
 
 from ..constants import API_URL
 from .data_manager import DataManager
diff --git a/django/meleager/management/commands/check_database.py b/django/meleager/management/commands/check_database.py
new file mode 100644
index 0000000000000000000000000000000000000000..9321f0f42576f16fc6a4853dbe30dc62d55dc6a1
--- /dev/null
+++ b/django/meleager/management/commands/check_database.py
@@ -0,0 +1,37 @@
+from django.core.management.base import BaseCommand
+from django.db.models import Q
+
+from meleager.models import Text, Alignment
+
+def check_texts():
+    texts_associated_twice = Text.objects.exclude(passage=None).exclude(scholium=None)
+    if not texts_associated_twice.count():
+        return True
+
+    print("The following texts are linked to both a Passage and a Scholium:")
+    for text in texts_associated_twice:
+        print(text)
+
+    return False
+
+def check_alignments():
+    text_1_or_2_empty = Q(text_1=None) | Q(text_2=None)
+    incorrect_alignments = Alignment.objects.filter(text_1_or_2_empty)
+    if not incorrect_alignments:
+        return True
+
+    for alignment in incorrect_alignments:
+        print(f"{alignment.pk} - {alignment.text_1} {alignment.text_2}")
+
+    return False
+
+class Command(BaseCommand):
+    help = "Checks database integrity"
+
+    def handle(self, *args, **options):
+        texts_ok = check_texts()
+        alignments_ok = check_alignments()
+
+        if texts_ok and alignments_ok:
+            print("All good.")
+
diff --git a/django/meleager/migrations/0001_initial.py b/django/meleager/migrations/0001_initial.py
index f44006c6b32324ceb7bbf3a0bb67c79a467b84a8..531dd50d53f8c3c752b6977b76c7ce48145ed706 100644
--- a/django/meleager/migrations/0001_initial.py
+++ b/django/meleager/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 3.1.3 on 2020-11-28 03:15
+# Generated by Django 3.1.3 on 2021-02-05 22:20
 
 import django.contrib.gis.db.models.fields
 import django.contrib.postgres.fields.ranges
@@ -92,6 +92,14 @@ class Migration(migrations.Migration):
                 'abstract': False,
             },
         ),
+        migrations.CreateModel(
+            name='CommentTextAnchor',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('anchor_word', models.CharField(default=' ', max_length=255)),
+                ('occurrence', models.IntegerField(default=0)),
+            ],
+        ),
         migrations.CreateModel(
             name='Description',
             fields=[
@@ -163,7 +171,11 @@ class Migration(migrations.Migration):
             fields=[
                 ('code', models.CharField(max_length=3, primary_key=True, serialize=False, verbose_name='Language code (ISO639)')),
                 ('iso_name', models.CharField(max_length=100, verbose_name='Language name')),
+                ('preferred', models.BooleanField(db_index=True, default=False, verbose_name='Favorite language')),
             ],
+            options={
+                'ordering': ('-preferred', 'code'),
+            },
         ),
         migrations.CreateModel(
             name='Manuscript',
@@ -215,7 +227,7 @@ class Migration(migrations.Migration):
                 ('sub_fragment', models.CharField(blank=True, db_index=True, default='', help_text='The sub fragment number in book.', max_length=3)),
             ],
             options={
-                'abstract': False,
+                'permissions': [('manage_own_passage', 'Can manage (edit/delete) their own Passage items')],
             },
         ),
         migrations.CreateModel(
@@ -273,7 +285,7 @@ class Migration(migrations.Migration):
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
                 ('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
-                ('alternative_urns', models.ManyToManyField(help_text='The alternative URNs of this resource.', related_name='meleager_work_alternative_urns', related_query_name='meleager_works_alternative_urns', to='meleager.URN')),
+                ('alternative_urns', models.ManyToManyField(blank=True, help_text='The alternative URNs of this resource.', related_name='meleager_work_alternative_urns', related_query_name='meleager_works_alternative_urns', to='meleager.URN')),
             ],
             options={
                 'abstract': False,
diff --git a/django/meleager/migrations/0002_auto_20201128_0315.py b/django/meleager/migrations/0002_auto_20210205_2220.py
similarity index 87%
rename from django/meleager/migrations/0002_auto_20201128_0315.py
rename to django/meleager/migrations/0002_auto_20210205_2220.py
index 3cba8e8b71e800e2b60a2261eb482d402b338b6d..03d57019a8ff175afa240a108f487e5c1f109e8c 100644
--- a/django/meleager/migrations/0002_auto_20201128_0315.py
+++ b/django/meleager/migrations/0002_auto_20210205_2220.py
@@ -1,4 +1,4 @@
-# Generated by Django 3.1.3 on 2020-11-28 03:15
+# Generated by Django 3.1.3 on 2021-02-05 22:20
 
 from django.conf import settings
 from django.db import migrations, models
@@ -23,18 +23,13 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='work',
             name='descriptions',
-            field=models.ManyToManyField(help_text='The multilingual descriptions for this object.', related_name='meleager_work', related_query_name='meleager_works', to='meleager.Description'),
+            field=models.ManyToManyField(help_text='Descriptions for the work.', related_name='works', to='meleager.Description'),
         ),
         migrations.AddField(
             model_name='work',
             name='last_editor',
             field=models.ForeignKey(help_text='The last editor of this resource.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='meleager_work_last_editor', related_query_name='meleager_works_last_editor', to=settings.AUTH_USER_MODEL),
         ),
-        migrations.AddField(
-            model_name='work',
-            name='names',
-            field=models.ManyToManyField(help_text='The multilingual names for this object.', related_name='mlgr_works', to='meleager.Name'),
-        ),
         migrations.AddField(
             model_name='work',
             name='urn',
@@ -53,7 +48,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='urn',
             name='keywords',
-            field=models.ManyToManyField(help_text='The keywords that tag this resource.', related_name='mlgr_urns', to='meleager.Keyword'),
+            field=models.ManyToManyField(help_text='The keywords that tag this resource.', related_name='urns', to='meleager.Keyword'),
         ),
         migrations.AddField(
             model_name='urn',
@@ -63,7 +58,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='text',
             name='comments',
-            field=models.ManyToManyField(help_text='The comments of this resource.', related_name='texts', to='meleager.Comment'),
+            field=models.ManyToManyField(help_text='The comments of this resource.', related_name='texts', through='meleager.CommentTextAnchor', to='meleager.Comment'),
         ),
         migrations.AddField(
             model_name='text',
@@ -98,17 +93,17 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='scholium',
             name='alternative_urns',
-            field=models.ManyToManyField(help_text='The alternative URNs of this resource.', related_name='meleager_scholium_alternative_urns', related_query_name='meleager_scholiums_alternative_urns', to='meleager.URN'),
+            field=models.ManyToManyField(blank=True, help_text='The alternative URNs of this resource.', related_name='meleager_scholium_alternative_urns', related_query_name='meleager_scholiums_alternative_urns', to='meleager.URN'),
         ),
         migrations.AddField(
             model_name='scholium',
             name='city',
-            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mlgr_scholia', to='meleager.city'),
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scholia', to='meleager.city'),
         ),
         migrations.AddField(
             model_name='scholium',
             name='comments',
-            field=models.ManyToManyField(help_text='The comments of this resource.', related_name='mlgr_scholia', to='meleager.Comment'),
+            field=models.ManyToManyField(blank=True, help_text='The comments of this resource.', related_name='scholia', to='meleager.Comment'),
         ),
         migrations.AddField(
             model_name='scholium',
@@ -123,12 +118,12 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='scholium',
             name='images',
-            field=models.ManyToManyField(help_text='The images associated to this resource.', related_name='mlgr_scholia', to='meleager.Medium'),
+            field=models.ManyToManyField(blank=True, help_text='The images associated to this resource.', related_name='scholia', to='meleager.Medium'),
         ),
         migrations.AddField(
             model_name='scholium',
             name='keywords',
-            field=models.ManyToManyField(help_text='The keywords that tag this resource.', related_name='mlgr_scholia', to='meleager.Keyword'),
+            field=models.ManyToManyField(blank=True, help_text='The keywords that tag this resource.', related_name='scholia', to='meleager.Keyword'),
         ),
         migrations.AddField(
             model_name='scholium',
@@ -138,12 +133,12 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='scholium',
             name='manuscripts',
-            field=models.ManyToManyField(help_text='The image of the manuscript for this passage.', to='meleager.Manuscript'),
+            field=models.ManyToManyField(blank=True, help_text='The image of the manuscript for this passage.', to='meleager.Manuscript'),
         ),
         migrations.AddField(
             model_name='scholium',
             name='passage',
-            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mlgr_scholia', to='meleager.passage'),
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scholia', to='meleager.passage'),
         ),
         migrations.AddField(
             model_name='scholium',
@@ -158,7 +153,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='passage',
             name='alternative_urns',
-            field=models.ManyToManyField(help_text='The alternative URNs of this resource.', related_name='meleager_passage_alternative_urns', related_query_name='meleager_passages_alternative_urns', to='meleager.URN'),
+            field=models.ManyToManyField(blank=True, help_text='The alternative URNs of this resource.', related_name='meleager_passage_alternative_urns', related_query_name='meleager_passages_alternative_urns', to='meleager.URN'),
         ),
         migrations.AddField(
             model_name='passage',
@@ -172,13 +167,13 @@ class Migration(migrations.Migration):
         ),
         migrations.AddField(
             model_name='passage',
-            name='city',
-            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='passages', to='meleager.city'),
+            name='cities',
+            field=models.ManyToManyField(related_name='passages', to='meleager.City'),
         ),
         migrations.AddField(
             model_name='passage',
             name='comments',
-            field=models.ManyToManyField(help_text='The comments of this resource.', related_name='mlgr_passages', to='meleager.Comment'),
+            field=models.ManyToManyField(help_text='The comments of this resource.', related_name='passages', to='meleager.Comment'),
         ),
         migrations.AddField(
             model_name='passage',
@@ -193,12 +188,12 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='passage',
             name='external_references',
-            field=models.ManyToManyField(related_name='mlgr_passages', to='meleager.ExternalReference'),
+            field=models.ManyToManyField(related_name='passages', to='meleager.ExternalReference'),
         ),
         migrations.AddField(
             model_name='passage',
             name='images',
-            field=models.ManyToManyField(help_text='The images associated to this resource.', related_name='mlgr_passages', to='meleager.Medium'),
+            field=models.ManyToManyField(help_text='The images associated to this resource.', related_name='passages', to='meleager.Medium'),
         ),
         migrations.AddField(
             model_name='passage',
@@ -208,7 +203,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='passage',
             name='keywords',
-            field=models.ManyToManyField(help_text='The keywords that tag this resource.', related_name='mlgr_passages', to='meleager.Keyword'),
+            field=models.ManyToManyField(blank=True, help_text='The keywords that tag this resource.', related_name='passages', to='meleager.Keyword'),
         ),
         migrations.AddField(
             model_name='passage',
@@ -218,7 +213,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='passage',
             name='manuscripts',
-            field=models.ManyToManyField(help_text='The image of the manuscript for this passage.', related_name='mlgr_passages', to='meleager.Manuscript'),
+            field=models.ManyToManyField(help_text='The image of the manuscript for this passage.', related_name='passages', to='meleager.Manuscript'),
         ),
         migrations.AddField(
             model_name='passage',
@@ -253,7 +248,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='medium',
             name='keywords',
-            field=models.ManyToManyField(help_text='The keywords that tag this resource.', related_name='mlgr_media', to='meleager.Keyword'),
+            field=models.ManyToManyField(help_text='The keywords that tag this resource.', related_name='media', to='meleager.Keyword'),
         ),
         migrations.AddField(
             model_name='medium',
@@ -273,7 +268,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='manuscript',
             name='keywords',
-            field=models.ManyToManyField(help_text='The keywords that tag this resource.', related_name='mlgr_manuscripts', to='meleager.Keyword'),
+            field=models.ManyToManyField(help_text='The keywords that tag this resource.', related_name='manuscripts', to='meleager.Keyword'),
         ),
         migrations.AddField(
             model_name='manuscript',
@@ -298,12 +293,12 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='keywordcategory',
             name='names',
-            field=models.ManyToManyField(help_text='The multilingual names for this object.', related_name='mlgr_keyword_categories', to='meleager.Name'),
+            field=models.ManyToManyField(help_text='The multilingual names for this object.', related_name='keyword_categories', to='meleager.Name'),
         ),
         migrations.AddField(
             model_name='keyword',
             name='category',
-            field=models.ForeignKey(help_text='The category this keyword belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='meleager.keywordcategory'),
+            field=models.ForeignKey(help_text='The category this keyword belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='keywords', to='meleager.keywordcategory'),
         ),
         migrations.AddField(
             model_name='keyword',
@@ -323,7 +318,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='keyword',
             name='names',
-            field=models.ManyToManyField(help_text='The multilingual names for this object.', related_name='mlgr_keywords', to='meleager.Name'),
+            field=models.ManyToManyField(help_text='The multilingual names for this object.', related_name='keywords', to='meleager.Name'),
         ),
         migrations.AddField(
             model_name='editor',
@@ -363,7 +358,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='edition',
             name='work',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='meleager.work'),
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='editions', to='meleager.work'),
         ),
         migrations.AddField(
             model_name='description',
@@ -380,6 +375,16 @@ class Migration(migrations.Migration):
             name='last_editor',
             field=models.ForeignKey(help_text='The last editor of this resource.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='meleager_description_last_editor', related_query_name='meleager_descriptions_last_editor', to=settings.AUTH_USER_MODEL),
         ),
+        migrations.AddField(
+            model_name='commenttextanchor',
+            name='comment',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='meleager.comment'),
+        ),
+        migrations.AddField(
+            model_name='commenttextanchor',
+            name='text',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='meleager.text'),
+        ),
         migrations.AddField(
             model_name='comment',
             name='creator',
@@ -393,7 +398,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='comment',
             name='images',
-            field=models.ManyToManyField(help_text='The images associated to this resource.', related_name='mlgr_comments', to='meleager.Medium'),
+            field=models.ManyToManyField(blank=True, help_text='The images associated to this resource.', related_name='comments', to='meleager.Medium'),
         ),
         migrations.AddField(
             model_name='comment',
@@ -418,7 +423,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='city',
             name='names',
-            field=models.ManyToManyField(help_text='The multilingual names for this object.', related_name='mlgr_cities', to='meleager.Name'),
+            field=models.ManyToManyField(help_text='The multilingual names for this object.', related_name='cities', to='meleager.Name'),
         ),
         migrations.AddField(
             model_name='book',
@@ -443,17 +448,17 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='author',
             name='alternative_urns',
-            field=models.ManyToManyField(help_text='The alternative URNs of this resource.', related_name='meleager_author_alternative_urns', related_query_name='meleager_authors_alternative_urns', to='meleager.URN'),
+            field=models.ManyToManyField(blank=True, help_text='The alternative URNs of this resource.', related_name='meleager_author_alternative_urns', related_query_name='meleager_authors_alternative_urns', to='meleager.URN'),
         ),
         migrations.AddField(
             model_name='author',
             name='city_born',
-            field=models.ForeignKey(blank=True, help_text='The city where this author was born.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='mlgr_authors_born', related_query_name='meleager_authors_city_born', to='meleager.city'),
+            field=models.ForeignKey(blank=True, help_text='The city where this author was born.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='authors_born_at', related_query_name='meleager_authors_city_born', to='meleager.city'),
         ),
         migrations.AddField(
             model_name='author',
             name='city_died',
-            field=models.ForeignKey(blank=True, help_text='The city where this author died.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='mlgr_authors_died', to='meleager.city'),
+            field=models.ForeignKey(blank=True, help_text='The city where this author died.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='authors_died_at', to='meleager.city'),
         ),
         migrations.AddField(
             model_name='author',
@@ -468,7 +473,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='author',
             name='images',
-            field=models.ManyToManyField(help_text='The images associated to this resource.', related_name='mlgr_authors', to='meleager.Medium'),
+            field=models.ManyToManyField(blank=True, help_text='The images associated to this resource.', related_name='authors', to='meleager.Medium'),
         ),
         migrations.AddField(
             model_name='author',
@@ -478,7 +483,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='author',
             name='names',
-            field=models.ManyToManyField(help_text='The multilingual names for this object.', related_name='mlgr_authors', to='meleager.Name'),
+            field=models.ManyToManyField(help_text='The multilingual names for this object.', related_name='authors', to='meleager.Name'),
         ),
         migrations.AddField(
             model_name='author',
diff --git a/django/meleager/migrations/0003_auto_20201216_0308.py b/django/meleager/migrations/0003_auto_20201216_0308.py
deleted file mode 100644
index 4c92f7c83e54030c8e3b9536660b0470d7b5a920..0000000000000000000000000000000000000000
--- a/django/meleager/migrations/0003_auto_20201216_0308.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Generated by Django 3.1.3 on 2020-12-16 03:08
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('meleager', '0002_auto_20201128_0315'),
-    ]
-
-    operations = [
-        migrations.RemoveField(
-            model_name='work',
-            name='names',
-        ),
-        migrations.AddField(
-            model_name='language',
-            name='preferred',
-            field=models.BooleanField(db_index=True, default=False, verbose_name='Favorite language'),
-        ),
-        migrations.AlterField(
-            model_name='work',
-            name='descriptions',
-            field=models.ManyToManyField(help_text='Descriptions for the work.', related_name='mlgr_works', to='meleager.Description'),
-        ),
-    ]
diff --git a/django/meleager/migrations/0004_auto_20201216_2034.py b/django/meleager/migrations/0004_auto_20201216_2034.py
deleted file mode 100644
index 61dd34799c13f9e2369707432925b2b4df875e49..0000000000000000000000000000000000000000
--- a/django/meleager/migrations/0004_auto_20201216_2034.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Generated by Django 3.1.3 on 2020-12-16 20:34
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('meleager', '0003_auto_20201216_0308'),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name='keyword',
-            name='category',
-            field=models.ForeignKey(help_text='The category this keyword belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='keywords', to='meleager.keywordcategory'),
-        ),
-    ]
diff --git a/django/meleager/models/author.py b/django/meleager/models/author.py
index a5d7aff43f4538b9cc6c4583f829d515bc929718..96facb873ec8943308cf85458bcef8b9c338995a 100644
--- a/django/meleager/models/author.py
+++ b/django/meleager/models/author.py
@@ -14,7 +14,7 @@ class Author(
 ):
     names = models.ManyToManyField(
         "meleager.Name",
-        related_name="mlgr_authors",
+        related_name="authors",
         help_text="The multilingual names for this object.",
     )
 
@@ -23,7 +23,7 @@ class Author(
         on_delete=models.PROTECT,
         null=True,
         blank=True,
-        related_name="mlgr_authors_born",
+        related_name="authors_born_at",
         related_query_name="%(app_label)s_%(class)ss_city_born",
         help_text="The city where this author was born.",
     )
@@ -37,14 +37,15 @@ class Author(
         on_delete=models.PROTECT,
         null=True,
         blank=True,
-        related_name="mlgr_authors_died",
+        related_name="authors_died_at",
         help_text="The city where this author died.",
     )
 
     images = models.ManyToManyField(
         "meleager.Medium",
-        related_name="mlgr_authors",
+        related_name="authors",
         help_text="The images associated to this resource.",
+        blank=True,
     )
 
     died_range_year_date = IntegerRangeField(
diff --git a/django/meleager/models/city.py b/django/meleager/models/city.py
index d11143d9d3ab6f5a4143ac7f7bf3a7069b886a0d..cba1322ffdd431c09d3acf7e1ad0312030047110 100644
--- a/django/meleager/models/city.py
+++ b/django/meleager/models/city.py
@@ -7,7 +7,7 @@ from .mixins import DescriptableResourceMixin, EditableResourceMixin
 class City(EditableResourceMixin, DescriptableResourceMixin, models.Model):
     names = models.ManyToManyField(
         "meleager.Name",
-        related_name="mlgr_cities",
+        related_name="cities",
         help_text="The multilingual names for this object.",
     )
 
diff --git a/django/meleager/models/comment.py b/django/meleager/models/comment.py
index 47ccb1dde2f96f3faab2701611e913bcbb9175fe..a9a5d4d5c35d90b4d6976fd559cafb426cf8de6c 100644
--- a/django/meleager/models/comment.py
+++ b/django/meleager/models/comment.py
@@ -10,8 +10,9 @@ class CommentType(models.TextChoices):
 class Comment(EditableResourceMixin, DescriptableResourceMixin, models.Model):
     images = models.ManyToManyField(
         "meleager.Medium",
-        related_name="mlgr_comments",
+        related_name="comments",
         help_text="The images associated to this resource.",
+        blank=True,
     )
     comment_type = models.CharField(max_length=20, choices=CommentType.choices, default=CommentType.USER_NOTE, null=False, db_index=True)
     comment_title = models.TextField()
diff --git a/django/meleager/models/edition.py b/django/meleager/models/edition.py
index 46e2d2640bbf5bc228f6d3257853f3bd6e84622c..8e382f37d8f65c2c0ee1940b1ee06c457c9e1cd1 100644
--- a/django/meleager/models/edition.py
+++ b/django/meleager/models/edition.py
@@ -11,7 +11,7 @@ class Edition(EditableResourceMixin, DescriptableResourceMixin, models.Model):
         SCHOLIA = 3
         ALIGNMENT = 4
 
-    work = models.ForeignKey("meleager.Work", on_delete=models.CASCADE)
+    work = models.ForeignKey("meleager.Work", on_delete=models.CASCADE, related_name="editions")
     edition_type = models.IntegerField(choices=EditionType.choices)
     metadata = models.JSONField("Metadata for Edition", null=False, default=dict)
 
diff --git a/django/meleager/models/keyword.py b/django/meleager/models/keyword.py
index 775825b98ae69f29a41610cd9a0ec042c74451fa..aebbbcdf12a4d958e317c9c36e6545fe1d29c3f0 100644
--- a/django/meleager/models/keyword.py
+++ b/django/meleager/models/keyword.py
@@ -6,7 +6,7 @@ from .mixins import DescriptableResourceMixin, EditableResourceMixin
 class KeywordCategory(EditableResourceMixin, DescriptableResourceMixin, models.Model):
     names = models.ManyToManyField(
         "meleager.Name",
-        related_name="mlgr_keyword_categories",
+        related_name="keyword_categories",
         help_text="The multilingual names for this object.",
     )
 
@@ -23,7 +23,7 @@ class KeywordCategory(EditableResourceMixin, DescriptableResourceMixin, models.M
 class Keyword(EditableResourceMixin, DescriptableResourceMixin, models.Model):
     names = models.ManyToManyField(
         "meleager.Name",
-        related_name="mlgr_keywords",
+        related_name="keywords",
         help_text="The multilingual names for this object.",
     )
 
diff --git a/django/meleager/models/medium.py b/django/meleager/models/medium.py
index 11297a4df9a80165e19f377f27f71df3f045565e..89e009bc62beb9b0f9399e886db916b2d9e6f93a 100644
--- a/django/meleager/models/medium.py
+++ b/django/meleager/models/medium.py
@@ -13,7 +13,7 @@ class MediaTypes(models.TextChoices):
 class Medium(EditableResourceMixin, DescriptableResourceMixin, models.Model):
     keywords = models.ManyToManyField(
         "meleager.Keyword",
-        related_name="mlgr_media",
+        related_name="media",
         help_text="The keywords that tag this resource.",
     )
     type = models.CharField(
@@ -31,7 +31,7 @@ class Manuscript(EditableResourceMixin, DescriptableResourceMixin, models.Model)
     credit = models.CharField(max_length=255, null=False, blank=True, default="")
     keywords = models.ManyToManyField(
         "meleager.Keyword",
-        related_name="mlgr_manuscripts",
+        related_name="manuscripts",
         help_text="The keywords that tag this resource.",
     )
 
diff --git a/django/meleager/models/mixins/urn_mixin.py b/django/meleager/models/mixins/urn_mixin.py
index 2c991aa891ea9d6ff129f5da59197c46e6871026..0af37576eb4b8435b80969bfeb3a5b9f24d1216a 100644
--- a/django/meleager/models/mixins/urn_mixin.py
+++ b/django/meleager/models/mixins/urn_mixin.py
@@ -21,6 +21,7 @@ class AlternativeURNResourceMixin(models.Model):
         related_name="%(app_label)s_%(class)s_alternative_urns",
         related_query_name="%(app_label)s_%(class)ss_alternative_urns",
         help_text="The alternative URNs of this resource.",
+        blank=True,
     )
 
     class Meta:
diff --git a/django/meleager/models/passage.py b/django/meleager/models/passage.py
index 3c15e4a45e8eb1e0b8065fd1070769b7755712bb..11565f2f2cca980ff73f62e625ad66621d8c503f 100644
--- a/django/meleager/models/passage.py
+++ b/django/meleager/models/passage.py
@@ -1,5 +1,6 @@
 import re
 from urllib.parse import urlparse
+from pathlib import Path
 
 from django.apps import apps
 from django.conf import settings
@@ -49,12 +50,9 @@ class Passage(
     objects = PassageManager()
 
     authors = models.ManyToManyField("meleager.Author")
-    city = models.ForeignKey(
+    cities = models.ManyToManyField(
         "meleager.City",
         related_name="passages",
-        on_delete=models.SET_NULL,
-        null=True,
-        blank=True,
     )
 
     book = models.ForeignKey(
@@ -80,35 +78,51 @@ class Passage(
 
     images = models.ManyToManyField(
         "meleager.Medium",
-        related_name="mlgr_passages",
+        related_name="passages",
         help_text="The images associated to this resource.",
     )
 
     comments = models.ManyToManyField(
         "meleager.Comment",
-        related_name="mlgr_passages",
+        related_name="passages",
         help_text="The comments of this resource.",
     )
 
     alternative_uris = models.ManyToManyField("meleager.AlternativeUri")
-    external_references = models.ManyToManyField("meleager.ExternalReference", related_name="mlgr_passages")
+    external_references = models.ManyToManyField("meleager.ExternalReference", related_name="passages")
     internal_references = models.ManyToManyField("self", symmetrical=True)
 
     manuscripts = models.ManyToManyField(
         "meleager.Manuscript",
         help_text="The image of the manuscript for this passage.",
-        related_name="mlgr_passages",
+        related_name="passages",
     )
 
     keywords = models.ManyToManyField(
         "meleager.Keyword",
-        related_name="mlgr_passages",
+        related_name="passages",
         help_text="The keywords that tag this resource.",
+        blank=True,
     )
 
+    def get_absolute_url(self):
+        return reverse(
+            "web:passage-detail",
+            args=(self.book.number, self.fragment, self.sub_fragment),
+        )
+
+    @property
+    def urn_value(self):
+        return Path(urlparse(self.get_absolute_url()).path).name
+    
     def natural_key(self):
         return (self.fragment, self.sub_fragment) + self.book.natural_key()
-    natural_key.dependencies = ['meleager.Work', 'meleager.Book']
+    natural_key.dependencies = ['meleager.Work', 'meleager.Book', 'meleager.Author']
 
     def __str__(self):
         return f"Passage {self.book.number}.{self.fragment}{self.sub_fragment}"
+
+    class Meta:
+        permissions = [
+            ("manage_own_passage", "Can manage (edit/delete) their own Passage items")
+        ]
\ No newline at end of file
diff --git a/django/meleager/models/scholium.py b/django/meleager/models/scholium.py
index 3e8763d256e5519dd65ab69f636708afbcbd68a5..167e55cea9ad2eb48aa1355356b45c11a7ce7018 100644
--- a/django/meleager/models/scholium.py
+++ b/django/meleager/models/scholium.py
@@ -1,4 +1,5 @@
 from django.db import models
+from django.urls import reverse
 
 from .mixins import (AlternativeURNResourceMixin, DescriptableResourceMixin,
                      EditableResourceMixin, ValidableResourceMixin)
@@ -15,39 +16,47 @@ class Scholium(
 
     passage = models.ForeignKey(
         "meleager.Passage",
-        related_name="mlgr_scholia",
+        related_name="scholia",
         on_delete=models.CASCADE,
         null=True,
     )
     city = models.ForeignKey(
         "meleager.City",
-        related_name="mlgr_scholia",
+        related_name="scholia",
         on_delete=models.SET_NULL,
-        null=True,
+        null=True, blank=True,
     )
 
     manuscripts = models.ManyToManyField(
-        "meleager.Manuscript", help_text="The image of the manuscript for this passage."
+        "meleager.Manuscript", help_text="The image of the manuscript for this passage.", blank=True,
     )
 
     images = models.ManyToManyField(
         "meleager.Medium",
-        related_name="mlgr_scholia",
-        help_text="The images associated to this resource.",
+        related_name="scholia",
+        help_text="The images associated to this resource.", blank=True,
     )
 
     comments = models.ManyToManyField(
         "meleager.Comment",
-        related_name="mlgr_scholia",
-        help_text="The comments of this resource.",
+        related_name="scholia",
+        help_text="The comments of this resource.", blank=True,
     )
 
     keywords = models.ManyToManyField(
         "meleager.Keyword",
-        related_name="mlgr_scholia",
-        help_text="The keywords that tag this resource.",
+        related_name="scholia",
+        help_text="The keywords that tag this resource.", blank=True,
     )
 
+    def get_absolute_url(self):
+        return reverse(
+            "web:scholium",
+            self.passage.book.number,
+            self.passage.fragment,
+            self.passage.sub_fragment,
+        )
+    
     def __str__(self):
         return f"Scholium {self.passage.book.number}.{self.passage.fragment}{self.passage.sub_fragment}.{self.number}"
 
diff --git a/django/meleager/models/text.py b/django/meleager/models/text.py
index 512ea3bd6320792a33918f7e4516d584540d919c..f96b6fbffb2568a465c0240721806dc0a7b85e74 100644
--- a/django/meleager/models/text.py
+++ b/django/meleager/models/text.py
@@ -4,6 +4,11 @@ from django.template.defaultfilters import truncatewords
 from .helpers import Language
 from .mixins import EditableResourceMixin, ValidableResourceMixin
 
+class CommentTextAnchor(models.Model):
+    text = models.ForeignKey('meleager.Text', on_delete=models.CASCADE, null=False)
+    comment = models.ForeignKey('meleager.Comment', on_delete=models.CASCADE, null=False)
+    anchor_word = models.CharField(max_length=255, default=" ", null=False)
+    occurrence = models.IntegerField(default=0)
 
 class Text(EditableResourceMixin, ValidableResourceMixin, models.Model):
     class Status(models.IntegerChoices):
@@ -43,7 +48,9 @@ class Text(EditableResourceMixin, ValidableResourceMixin, models.Model):
         "meleager.Comment",
         related_name="texts",
         help_text="The comments of this resource.",
+        through=CommentTextAnchor,
     )
 
     def __str__(self):
         return f"Text ({truncatewords(self.text, 5)})"
+
diff --git a/django/meleager/models/urn.py b/django/meleager/models/urn.py
index 1b6d1ffe0a89b6ba24d7af853886bd0822f960f3..98bc39b3e8eb313334ad65714f4faadb8154b9ef 100644
--- a/django/meleager/models/urn.py
+++ b/django/meleager/models/urn.py
@@ -6,7 +6,7 @@ from .mixins import DescriptableResourceMixin, EditableResourceMixin
 class URN(EditableResourceMixin, DescriptableResourceMixin, models.Model):
     keywords = models.ManyToManyField(
         "meleager.Keyword",
-        related_name="mlgr_urns",
+        related_name="urns",
         help_text="The keywords that tag this resource.",
     )
     # example of perseus URN: urn:cts:greekLit:tlg7000.tlg001.perseus-grc2:7.281
diff --git a/django/meleager/models/work.py b/django/meleager/models/work.py
index ef38a6678df3ed49bf0c4cf3f376d1d5f321e957..7a264f605bc272f8eed89ae743f9c51b376de028 100644
--- a/django/meleager/models/work.py
+++ b/django/meleager/models/work.py
@@ -13,7 +13,7 @@ class Work(
 
     descriptions = models.ManyToManyField(
         "meleager.Description",
-        related_name="mlgr_works",
+        related_name="works",
         help_text="Descriptions for the work.",
     )
 
diff --git a/django/meleager/schema.py b/django/meleager/schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..0db9d4e4a539e04af7d86729771b926e955c4feb
--- /dev/null
+++ b/django/meleager/schema.py
@@ -0,0 +1,63 @@
+import graphene
+from graphene import relay
+from graphene_django import DjangoObjectType, DjangoListField, DjangoConnectionField
+from graphene_django.filter import DjangoFilterConnectionField
+
+from meleager.models import Book, Passage, Scholium, City, Name, Author, Work, Description, Language
+
+class LanguageNode(DjangoObjectType):
+    class Meta:
+        model = Language
+
+class WorkNode(DjangoObjectType):
+    class Meta:
+        model = Work
+
+class DescriptionNode(DjangoObjectType):
+    class Meta:
+        model = Description
+
+class BookNode(DjangoObjectType):
+    class Meta:
+        model = Book
+        # filter_fields = ["id", "number"]
+        # interfaces = [relay.Node]
+
+class ScholiumNode(DjangoObjectType):
+    class Meta:
+        model = Scholium
+        # filter_fields = ["id", "number", "passage"]
+        # interfaces = [relay.Node]
+
+class PassageNode(DjangoObjectType):
+    class Meta:
+        model = Passage
+        # filter_fields = {
+        #     "id": ["exact"],
+        #     "fragment": ["exact"],
+        #     "sub_fragment": ["exact"],
+        #     "mlgr_scholia": ["exact"],
+        # }
+        # interfaces = [relay.Node]
+
+class CityNode(DjangoObjectType):
+    class Meta:
+        model = City
+        exclude = ["location"]
+
+class NameNode(DjangoObjectType):
+    class Meta:
+        model = Name
+
+class AuthorNode(DjangoObjectType):
+    class Meta:
+        model = Author
+
+class Query(graphene.ObjectType):
+    scholia = DjangoListField(ScholiumNode)
+    passages = DjangoListField(PassageNode)
+    names = DjangoListField(NameNode)
+    cities = DjangoListField(CityNode)
+    authors = DjangoListField(AuthorNode)
+
+schema = graphene.Schema(query=Query)
\ No newline at end of file
diff --git a/django/meleager/serializers/comment.py b/django/meleager/serializers/comment.py
index e4643c9da03e5a63ac5c624fd8c0437306e1d124..9e95840ec83c9c728400f68f00eba943fef47796 100644
--- a/django/meleager/serializers/comment.py
+++ b/django/meleager/serializers/comment.py
@@ -9,5 +9,5 @@ class CommentSerializer(serializers.HyperlinkedModelSerializer):
         fields = "__all__"
 
     passages = serializers.HyperlinkedRelatedField(
-        many=True, read_only=True, source="mlgr_passages", view_name="passage-detail"
+        many=True, read_only=True, source="passages", view_name="passage-detail"
     )
diff --git a/django/meleager/serializers/passage.py b/django/meleager/serializers/passage.py
index 13ab77021f43ee2ee0f27e12cdd2342c8db84847..120fe1755007a32a85bee53ee1db9c85c56423b3 100644
--- a/django/meleager/serializers/passage.py
+++ b/django/meleager/serializers/passage.py
@@ -12,6 +12,10 @@ class PassageSerializer(serializers.HyperlinkedModelSerializer):
         many=True, read_only=True, view_name="text-detail"
     )
     keywords = MinimalLinkedKeywordSerializer(many=True)
+    scholia = serializers.HyperlinkedRelatedField(
+        source="scholia",
+        many=True, read_only=True, view_name="scholium-detail",
+    )
 
     class Meta:
         model = Passage
diff --git a/django/meleager/serializers/work.py b/django/meleager/serializers/work.py
index 5936f4e833b087aa57b638e4f514e9d6113e7d90..a213de6ea271ca6440d5a789db8c0b2511f5de86 100644
--- a/django/meleager/serializers/work.py
+++ b/django/meleager/serializers/work.py
@@ -2,11 +2,11 @@ from rest_framework import serializers
 
 from meleager.models import Work
 
-from ..serializers.name import NameInlineSerializer
+from .description import DescriptionSerializer
 
 
 class WorkSerializer(serializers.HyperlinkedModelSerializer):
-    names = NameInlineSerializer(many=True)
+    descriptions = DescriptionSerializer(many=True)
 
     class Meta:
         model = Work
diff --git a/django/meleager/viewsets/passage.py b/django/meleager/viewsets/passage.py
index 07f453aee4455ea56061960c23409cb57f5d568b..7e8f213c61d1b7061e0d40db61260230d7d05e34 100644
--- a/django/meleager/viewsets/passage.py
+++ b/django/meleager/viewsets/passage.py
@@ -1,4 +1,7 @@
+import re
+
 from rest_framework import viewsets
+import django_filters.rest_framework
 
 from ..models import Passage
 from ..serializers.passage import PassageSerializer
@@ -8,3 +11,27 @@ class PassageViewSet(viewsets.ReadOnlyModelViewSet):
     queryset = Passage.objects.all()
     serializer_class = PassageSerializer
     http_method_names = ['get', 'head']
+    filter_backends = [django_filters.rest_framework.DjangoFilterBackend]
+    filterset_fields = ["book", "fragment", "sub_fragment"]
+
+    # def get_queryset(self):
+    #     qs = Passage.objects.all()
+    #     book = self.request.query_params.get('book', None)
+    #     fragment = self.request.query_params.get('fragment', None)
+    #     sub_fragment = None
+
+    #     if book is not None:
+    #         qs = qs.filter(book__number=book)
+
+    #     if fragment is not None:
+    #         regexp = r"(\d+)(\w*)"
+    #         matches = re.match(regexp, fragment)
+    #         if matches:
+    #             fragment = int(matches[1])
+    #             sub_fragment = matches[2]
+
+    #         qs = qs.filter(fragment=fragment)
+    #         if sub_fragment:
+    #             qs = qs.filter(sub_fragment=sub_fragment)
+
+    #     return qs
\ No newline at end of file
diff --git a/django/user/__init__.py b/django/meleager_user/__init__.py
similarity index 100%
rename from django/user/__init__.py
rename to django/meleager_user/__init__.py
diff --git a/django/user/admin.py b/django/meleager_user/admin.py
similarity index 100%
rename from django/user/admin.py
rename to django/meleager_user/admin.py
diff --git a/django/meleager_user/apps.py b/django/meleager_user/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..a96e5f82696670210a482c9403c863cf11a8f0ba
--- /dev/null
+++ b/django/meleager_user/apps.py
@@ -0,0 +1,4 @@
+from django.apps import AppConfig
+
+class MeleagerUserConfig(AppConfig):
+    name = "meleager_user"
diff --git a/django/meleager_user/auth_urls.py b/django/meleager_user/auth_urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..8927dff47907156f791d799a0859a1ced6715b17
--- /dev/null
+++ b/django/meleager_user/auth_urls.py
@@ -0,0 +1,8 @@
+from django.urls import path
+from .views import LoginView, LogoutView, PasswordResetView
+
+urlpatterns = [
+    path('login/', LoginView.as_view(), name="login"), 
+    path('logout/', LogoutView.as_view(), name="logout"),
+    path('reset/', PasswordResetView.as_view(), name="password_reset"),
+]
diff --git a/django/meleager_user/fixtures/admin_user.json b/django/meleager_user/fixtures/admin_user.json
new file mode 100644
index 0000000000000000000000000000000000000000..7a6cb664bc80ac05c92760234e46359cde16ac9e
--- /dev/null
+++ b/django/meleager_user/fixtures/admin_user.json
@@ -0,0 +1,38 @@
+[
+    {
+        "model": "meleager_user.user",
+        "fields": {
+            "password": "!cftze7QXrzidvow2x53dqyGZFqBHkZg63vEFGzJw",
+            "last_login": null,
+            "is_superuser": false,
+            "username": "AnonymousUser",
+            "first_name": "",
+            "last_name": "",
+            "email": "",
+            "is_staff": false,
+            "is_active": true,
+            "date_joined": "2020-11-12T06:14:06.173Z",
+            "institution": "",
+            "groups": [],
+            "user_permissions": []
+        }
+    },
+    {
+        "model": "meleager_user.user",
+        "fields": {
+            "password": "pbkdf2_sha256$216000$itKY4GPhYWHR$d1pd9QDaZZosNEzNcul6UCE/M3BtvK3DhRQiO1y6Ga4=",
+            "last_login": null,
+            "is_superuser": true,
+            "username": "test_admin",
+            "first_name": "",
+            "last_name": "",
+            "email": "admin@test.com",
+            "is_staff": true,
+            "is_active": true,
+            "date_joined": "2020-11-12T06:59:06.702Z",
+            "institution": "",
+            "groups": [],
+            "user_permissions": []
+        }
+    }
+]
\ No newline at end of file
diff --git a/django/meleager_user/fixtures/test_admin.json b/django/meleager_user/fixtures/test_admin.json
new file mode 100644
index 0000000000000000000000000000000000000000..cf4254fae35658fbd851494811820fedd2557835
--- /dev/null
+++ b/django/meleager_user/fixtures/test_admin.json
@@ -0,0 +1,40 @@
+[
+    {
+        "model": "meleager_user.user",
+        "pk": 1,
+        "fields": {
+            "password": "!xeTTOcyuHKdRJyOPkNaiXosuRkntzKOZKV26QBVo",
+            "last_login": null,
+            "is_superuser": false,
+            "username": "AnonymousUser",
+            "first_name": "",
+            "last_name": "",
+            "email": "",
+            "is_staff": false,
+            "is_active": true,
+            "date_joined": "2020-11-14T02:31:42.180Z",
+            "institution": "",
+            "groups": [],
+            "user_permissions": []
+        }
+    },
+    {
+        "model": "meleager_user.user",
+        "pk": 2,
+        "fields": {
+            "password": "pbkdf2_sha256$216000$1NDKDHZcziZa$sxZD899PegMg0IOiWOFso+F+gmSF5RXBPOkyrC6OvZ4=",
+            "last_login": null,
+            "is_superuser": true,
+            "username": "test_admin",
+            "first_name": "",
+            "last_name": "",
+            "email": "admin@admin.com",
+            "is_staff": true,
+            "is_active": true,
+            "date_joined": "2020-11-14T02:32:13.175Z",
+            "institution": "",
+            "groups": [],
+            "user_permissions": []
+        }
+    }
+]
\ No newline at end of file
diff --git a/django/user/migrations/0001_initial.py b/django/meleager_user/migrations/0001_initial.py
similarity index 95%
rename from django/user/migrations/0001_initial.py
rename to django/meleager_user/migrations/0001_initial.py
index 44c39599eaf2e8005451ab2664fab8bf5cd6caef..aa4e2ca68d66bab6aca53bd64262d76ce79d7adb 100644
--- a/django/user/migrations/0001_initial.py
+++ b/django/meleager_user/migrations/0001_initial.py
@@ -1,9 +1,9 @@
-# Generated by Django 3.1.3 on 2020-11-28 03:15
+# Generated by Django 3.1.3 on 2021-02-05 22:20
 
 import django.contrib.auth.validators
 from django.db import migrations, models
 import django.utils.timezone
-import user.models
+import meleager_user.models
 
 
 class Migration(migrations.Migration):
@@ -39,7 +39,7 @@ class Migration(migrations.Migration):
                 'abstract': False,
             },
             managers=[
-                ('objects', user.models.CustomUserManager()),
+                ('objects', meleager_user.models.CustomUserManager()),
             ],
         ),
     ]
diff --git a/django/user/migrations/__init__.py b/django/meleager_user/migrations/__init__.py
similarity index 100%
rename from django/user/migrations/__init__.py
rename to django/meleager_user/migrations/__init__.py
diff --git a/django/user/models.py b/django/meleager_user/models.py
similarity index 100%
rename from django/user/models.py
rename to django/meleager_user/models.py
diff --git a/django/meleager_user/templates/registration/login.html b/django/meleager_user/templates/registration/login.html
new file mode 100644
index 0000000000000000000000000000000000000000..2c39ee27e734463bccc3cb577adc94ea178171dc
--- /dev/null
+++ b/django/meleager_user/templates/registration/login.html
@@ -0,0 +1,35 @@
+<html>
+<title>Login page</title>
+{% if form.errors %}
+<p>Your username and password didn't match. Please try again.</p>
+{% endif %}
+
+{% if next %}
+{% if user.is_authenticated %}
+<p>Your account doesn't have access to this page. To proceed,
+    please login with an account that has access.</p>
+{% else %}
+<p>Please login to see this page.</p>
+{% endif %}
+{% endif %}
+
+<form method="post" action="{% url 'login' %}">
+    {% csrf_token %}
+    <table>
+        <tr>
+            <td>{{ form.username.label_tag }}</td>
+            <td>{{ form.username }}</td>
+        </tr>
+        <tr>
+            <td>{{ form.password.label_tag }}</td>
+            <td>{{ form.password }}</td>
+        </tr>
+    </table>
+
+    <input type="submit" value="login">
+    <input type="hidden" name="next" value="{{ next }}">
+</form>
+
+<!-- <p><a href="{% url 'password_reset' %}">Lost password?</a></p> -->
+
+</html>
\ No newline at end of file
diff --git a/django/meleager_user/templates/user/profile.html b/django/meleager_user/templates/user/profile.html
new file mode 100644
index 0000000000000000000000000000000000000000..4e40554573626e5fdb8927e0681bc16344c3c7ab
--- /dev/null
+++ b/django/meleager_user/templates/user/profile.html
@@ -0,0 +1,11 @@
+<html>
+    <title>
+        Profile page
+    </title>
+    <body>
+        <h2>
+            Profile page for {{ user }}
+        </h2>
+        <a href="{% url 'logout' %}">Logout</a>
+    </body>
+</html>
\ No newline at end of file
diff --git a/django/meleager_user/tests/__init__.py b/django/meleager_user/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/django/meleager_user/tests/test_login_form.py b/django/meleager_user/tests/test_login_form.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc83363dffe40189ee81bb57de14e2c41fe71afe
--- /dev/null
+++ b/django/meleager_user/tests/test_login_form.py
@@ -0,0 +1,24 @@
+from django.test import TestCase
+from django.urls import reverse
+from meleager_user.models import User
+
+class CommentsForms(TestCase):
+    fixtures = ["test_data.json"]
+
+    @classmethod
+    def setUpTestData(cls):
+        pass
+
+    def test_login_form_post(self):
+        response = self.client.post(
+            reverse("login"),
+            data={
+                "username": "test_admin",
+                "password": "admin12345",
+            },
+            follow=True
+        )
+
+        self.assertTrue(
+            response.context['user'].is_authenticated
+        )
diff --git a/django/meleager_user/user_urls.py b/django/meleager_user/user_urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..440aade0f9a2259c65a97c12a26a763448d14c4a
--- /dev/null
+++ b/django/meleager_user/user_urls.py
@@ -0,0 +1,6 @@
+from django.urls import path
+from .views import ProfileView
+
+urlpatterns = [
+    path('profile/', ProfileView.as_view(), name="profile"),
+]
diff --git a/django/meleager_user/views.py b/django/meleager_user/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..bb147d04018651527eb8c5a1bd029f77a670dd75
--- /dev/null
+++ b/django/meleager_user/views.py
@@ -0,0 +1,23 @@
+from django.views.generic import DetailView
+from django.contrib.auth.views import LoginView as DLoginView
+from django.contrib.auth.views import LogoutView as DLogoutView
+from django.contrib.auth.views import PasswordResetView as DPwdResetView
+from django.contrib.auth import get_user_model
+
+class LoginView(DLoginView):
+    template_name = "registration/login.html"
+
+class LogoutView(DLogoutView):
+    pass
+
+class PasswordResetView(DPwdResetView):
+    template_name = "registration/password_reset.html"
+
+class ProfileView(DetailView):
+    template_name = "user/profile.html"
+    model = get_user_model()
+
+    def get_object(self):
+        user = self.request.user
+        print(user)
+        return user
\ No newline at end of file
diff --git a/django/user/apps.py b/django/user/apps.py
deleted file mode 100644
index 1f2369a409abc934a14dfd5c460d9470639a9342..0000000000000000000000000000000000000000
--- a/django/user/apps.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from django.apps import AppConfig
-
-
-class UserConfig(AppConfig):
-    name = "user"
diff --git a/django/user/fixtures/admin_user.json b/django/user/fixtures/admin_user.json
deleted file mode 100644
index 9a9c8807394850a9794ea3d1658bdc39e27449ae..0000000000000000000000000000000000000000
--- a/django/user/fixtures/admin_user.json
+++ /dev/null
@@ -1 +0,0 @@
-[{"model": "user.user", "fields": {"password": "!cftze7QXrzidvow2x53dqyGZFqBHkZg63vEFGzJw", "last_login": null, "is_superuser": false, "username": "AnonymousUser", "first_name": "", "last_name": "", "email": "", "is_staff": false, "is_active": true, "date_joined": "2020-11-12T06:14:06.173Z", "institution": "", "groups": [], "user_permissions": []}},{"model": "user.user", "fields": {"password": "pbkdf2_sha256$216000$itKY4GPhYWHR$d1pd9QDaZZosNEzNcul6UCE/M3BtvK3DhRQiO1y6Ga4=", "last_login": null, "is_superuser": true, "username": "test_admin", "first_name": "", "last_name": "", "email": "admin@test.com", "is_staff": true, "is_active": true, "date_joined": "2020-11-12T06:59:06.702Z", "institution": "", "groups": [], "user_permissions": []}}]
\ No newline at end of file
diff --git a/django/user/fixtures/test_admin.json b/django/user/fixtures/test_admin.json
deleted file mode 100644
index 97df4b486f2fa0b7d65208a55bff44dfc3fd8ae4..0000000000000000000000000000000000000000
--- a/django/user/fixtures/test_admin.json
+++ /dev/null
@@ -1 +0,0 @@
-[{"model": "user.user", "pk": 1, "fields": {"password": "!xeTTOcyuHKdRJyOPkNaiXosuRkntzKOZKV26QBVo", "last_login": null, "is_superuser": false, "username": "AnonymousUser", "first_name": "", "last_name": "", "email": "", "is_staff": false, "is_active": true, "date_joined": "2020-11-14T02:31:42.180Z", "institution": "", "groups": [], "user_permissions": []}}, {"model": "user.user", "pk": 2, "fields": {"password": "pbkdf2_sha256$216000$1NDKDHZcziZa$sxZD899PegMg0IOiWOFso+F+gmSF5RXBPOkyrC6OvZ4=", "last_login": null, "is_superuser": true, "username": "test_admin", "first_name": "", "last_name": "", "email": "admin@admin.com", "is_staff": true, "is_active": true, "date_joined": "2020-11-14T02:32:13.175Z", "institution": "", "groups": [], "user_permissions": []}}]
\ No newline at end of file
diff --git a/django/web/forms/author.py b/django/web/forms/author.py
index 62bfbcd08cba465613726e09f7ffcea73096f84b..432ca61bc7dddacb99f4420cf54b8afd3edf49cb 100644
--- a/django/web/forms/author.py
+++ b/django/web/forms/author.py
@@ -5,7 +5,7 @@ from web.forms.name import NameWidget
 
 class AuthorForm(forms.ModelForm):
     names = forms.ModelMultipleChoiceField(
-        queryset=Name.objects.exclude(mlgr_authors=None), widget=NameWidget()
+        queryset=Name.objects.exclude(authors=None), widget=NameWidget()
     )
 
     class Meta:
diff --git a/django/web/forms/city.py b/django/web/forms/city.py
index 3b0d3c2a0b7dfe27a5cbc554a5bbb0e3b4d06c95..358e47cde3a361da9c069a1580efb1205b0f0a3b 100644
--- a/django/web/forms/city.py
+++ b/django/web/forms/city.py
@@ -6,7 +6,7 @@ from web.forms.name import NameWidget
 
 class CityForm(forms.ModelForm):
     names = forms.ModelMultipleChoiceField(
-        queryset=Name.objects.exclude(mlgr_cities=None), widget=NameWidget()
+        queryset=Name.objects.exclude(cities=None), widget=NameWidget()
     )
 
     descriptions = forms.ModelMultipleChoiceField(
diff --git a/django/web/forms/comment_description.py b/django/web/forms/comment_description.py
index e1638382fdee499565fa3415515ee0f973f1ea5c..b3e15f34d6daf44059e64144d805b7e7716f3d65 100644
--- a/django/web/forms/comment_description.py
+++ b/django/web/forms/comment_description.py
@@ -1,12 +1,18 @@
 from django import forms
-from django.utils.translation import gettext_lazy as _
 
+from meleager.models import Language
 
-class CommentDescriptionForm(forms.Form):
+
+class CommentCreateForm(forms.Form):
     comment_title = forms.CharField()
     description = forms.CharField(widget=forms.Textarea(attrs={"rows": 2, "cols": 40}))
-    language = forms.CharField(
-        min_length=3,
-        max_length=3,
-        help_text=_("The language must be 3 characters long."),
-    )
+    language = forms.ModelChoiceField(queryset=Language.objects.preferred())
+
+
+class CommentUpdateForm(forms.Form):
+    description = forms.CharField(widget=forms.Textarea(attrs={"rows": 2, "cols": 40}))
+    language = forms.ModelChoiceField(queryset=Language.objects.preferred())
+
+
+class CommentDeleteForm(forms.Form):
+    pk = forms.CharField(widget=forms.widgets.HiddenInput())
diff --git a/django/web/forms/passage.py b/django/web/forms/passage.py
index 5c73502c33573fa8508f4e87fa2ed05315e054a4..0b24bc66b449f814b2fe08796d806219fd402724 100644
--- a/django/web/forms/passage.py
+++ b/django/web/forms/passage.py
@@ -1,7 +1,8 @@
+from itertools import groupby
+
 from django import forms
-from web.models import APPassage
-from meleager.models import Author, Keyword
-from django.core.exceptions import ValidationError
+
+from meleager.models import Author, City, Keyword, Passage
 
 
 class PassageForm(forms.ModelForm):
@@ -9,23 +10,74 @@ class PassageForm(forms.ModelForm):
     sub_fragment = forms.CharField(disabled=True)
 
     class Meta:
-        model = APPassage
+        model = Passage
         fields = ["fragment", "sub_fragment"]
         exclude = ["urn", "images", "manuscripts", "keywords"]
 
-class AddEntityToPassageForm(forms.Form):
-    model = None
-    entity_pk = forms.IntegerField(widget=forms.TextInput())
 
-    def clean_entity_pk(self):
-        pk = self.cleaned_data["entity_pk"]
-        if not self.model.objects.filter(pk=pk).count():
-            raise ValidationError(f"Unknown {self.model._meta.verbose_name} {pk} in the database.")
+class AddAuthorToPassageForm(forms.Form):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        choices = [("", "-------------")]
+        choices += sorted(
+            [
+                (author.pk, ", ".join(name.name for name in author.names.all()))
+                for author in Author.objects.all()
+            ],
+            # Sort by name.
+            key=lambda x: x[1],
+        )
+        self.fields["author"] = forms.ChoiceField(choices=choices)
+
+
+class AddCityToPassageForm(forms.Form):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        choices = [("", "-------------")]
+        choices += sorted(
+            [
+                (
+                    city.pk,
+                    ", ".join(
+                        name.name for name in city.names.exclude(cities=None)
+                    ),
+                )
+                for city in City.objects.all()
+            ],
+            # Sort by name.
+            key=lambda x: x[1],
+        )
+        self.fields["city"] = forms.ChoiceField(choices=choices)
+
 
-        return pk
+class AddKeywordToPassageForm(forms.Form):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        choices = [("", "-------------")]
+        # We sort choices, first by `category` then by `name` to be able
+        # to propose a consistent optgroup on the HTML side.
+        keywords = sorted(
+            [
+                (
+                    keyword.pk,
+                    ", ".join(name.name for name in keyword.names.all()),
+                    keyword.category.names.first().name,
+                )
+                for keyword in Keyword.objects.select_related("category")
+            ],
+            # Sort by category.
+            key=lambda x: x[2],
+        )
+        choices += (
+            (
+                category,
+                # Sort by name, only keep pk (0) and name (1).
+                sorted([(item[0], item[1]) for item in items], key=lambda x: x[1]),
+            )
+            for category, items in groupby(keywords, key=lambda x: x[2])
+        )
+        self.fields["keyword"] = forms.ChoiceField(choices=choices)
 
-class AddAuthorToPassageForm(AddEntityToPassageForm):
-    model = Author
 
-class AddKeywordToPassageForm(AddEntityToPassageForm):
-    model = Keyword
\ No newline at end of file
+class RemoveFromPassageForm(forms.Form):
+    pk = forms.CharField(widget=forms.widgets.HiddenInput())
diff --git a/django/web/forms/passage_description.py b/django/web/forms/passage_description.py
new file mode 100644
index 0000000000000000000000000000000000000000..88a971aab0a26e4a02e076a27fb7532b4d241e88
--- /dev/null
+++ b/django/web/forms/passage_description.py
@@ -0,0 +1,17 @@
+from django import forms
+
+from meleager.models import Language
+
+
+class DescriptionCreateForm(forms.Form):
+    description = forms.CharField(widget=forms.Textarea(attrs={"rows": 2, "cols": 40}))
+    language = forms.ModelChoiceField(queryset=Language.objects.preferred())
+
+
+class DescriptionUpdateForm(forms.Form):
+    description = forms.CharField(widget=forms.Textarea(attrs={"rows": 2, "cols": 40}))
+    language = forms.ModelChoiceField(queryset=Language.objects.preferred())
+
+
+class DescriptionDeleteForm(forms.Form):
+    pk = forms.CharField(widget=forms.widgets.HiddenInput())
diff --git a/django/web/forms/text.py b/django/web/forms/text.py
index 6f3a43542d0e9c640cc9ad88441aba50905f2916..b6fc5c8ef27437744ed5b31f819ab63652243dc0 100644
--- a/django/web/forms/text.py
+++ b/django/web/forms/text.py
@@ -1,8 +1,11 @@
 from django import forms
-from meleager.models import Text
 
+from meleager.models import Language, Text
+
+
+class AddTextToPassageForm(forms.ModelForm):
+    language = forms.ModelChoiceField(queryset=Language.objects.preferred())
 
-class TextForm(forms.ModelForm):
     class Meta:
         model = Text
         fields = ("text", "language")
diff --git a/django/web/migrations/0001_initial.py b/django/web/migrations/0001_initial.py
deleted file mode 100644
index e31345fd016e5703659cf945821f289282c5e39c..0000000000000000000000000000000000000000
--- a/django/web/migrations/0001_initial.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# Generated by Django 3.1.3 on 2020-11-28 03:15
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    initial = True
-
-    dependencies = [
-        ('meleager', '0001_initial'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='APPassage',
-            fields=[
-            ],
-            options={
-                'proxy': True,
-                'indexes': [],
-                'constraints': [],
-            },
-            bases=('meleager.passage',),
-        ),
-        migrations.CreateModel(
-            name='APScholium',
-            fields=[
-            ],
-            options={
-                'proxy': True,
-                'indexes': [],
-                'constraints': [],
-            },
-            bases=('meleager.scholium',),
-        ),
-    ]
diff --git a/django/web/models.py b/django/web/models.py
deleted file mode 100644
index a7f93c2942a415d15eccd24718d21e5d67ac380e..0000000000000000000000000000000000000000
--- a/django/web/models.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from pathlib import Path
-from urllib.parse import urlparse
-
-from django.urls import reverse
-from meleager.models import Passage, Scholium
-
-
-class APPassage(Passage):
-    def get_absolute_url(self):
-        return reverse(
-            "web:passage-detail",
-            args=(self.book.number, self.fragment, self.sub_fragment),
-        )
-
-    @property
-    def urn_value(self):
-        return Path(urlparse(self.get_absolute_url()).path).name
-
-    class Meta:
-        proxy = True
-
-
-class APScholium(Scholium):
-    def get_absolute_url(self):
-        return reverse(
-            "web:scholium",
-            self.passage.book.number,
-            self.passage.fragment,
-            self.passage.sub_fragment,
-        )
-
-    class Meta:
-        proxy = True
diff --git a/django/web/static/web/css/styles.css b/django/web/static/web/css/styles.css
index c975917ce1140ab4d75cf6f95abf10d44f3bb81c..edbe95b36de5bc5aac9372c64507898da986e4de 100644
--- a/django/web/static/web/css/styles.css
+++ b/django/web/static/web/css/styles.css
@@ -2,6 +2,9 @@
 .content h2 {
     font-family: 'montserrat-medium';
 }
+.content h2 {
+    border-bottom: 1px solid var(--cirrus-gray);
+}
 
 .border-orange-300 {
     border-color: #f6b65c!important;
@@ -31,3 +34,68 @@ textarea {
     border-color:var(--cirrus-danger);
     background-color:rgba(244,67,54,.05)!important
 }
+
+.i18n-item {
+    border-radius: .25rem;
+    width: 100%;
+}
+.i18n-item:hover {
+    background: #fdeed1;
+}
+.i18n-item a {
+    margin-top: -2px;
+}
+
+.tag.tag--halflarge {
+    font-size: 110%;
+    padding: .1rem .6rem;
+}
+.tag-container-expand .tag--hover {
+    width: 0 !important;
+    padding: .1rem 0 !important;
+    visibility: hidden;
+    transition-property: width, padding;
+    transition-duration: .4s;
+    transition-timing-function: ease-in-out;
+}
+.tag-container-expand .tag--update,
+.tag-container-expand .tag--delete {
+    border-radius: .25rem !important;
+}
+.tag-container-expand:hover .tag--hover {
+    width: 6rem !important;
+    padding: .1rem .6rem !important;
+    visibility: visible;
+    transition-property: width, padding;
+    transition-duration: .4s;
+    transition-timing-function: ease-in-out;
+    border-radius: 0 .25rem .25rem 0 !important;
+}
+.tag-container-expand:hover .tag--update,
+.tag-container-expand:hover .tag--delete {
+    border-radius: .25rem 0 0 .25rem !important;
+}
+h2.tag-container-expand a {
+    font-size: 1.1rem;
+}
+
+
+.tag a {
+    color: inherit;
+    font-weight: inherit;
+}
+.svgicon {
+    display: inline-block;
+    width: 1.25rem;
+    height: 1.25rem;
+    stroke-width: 0;
+    stroke: currentColor;
+    fill: currentColor;
+}
+.svgicon-cross {
+    color: white;
+}
+
+.grid summary {
+    font-size: 1.7rem;
+}
diff --git a/django/web/static/web/img/symbol-defs.svg b/django/web/static/web/img/symbol-defs.svg
new file mode 100644
index 0000000000000000000000000000000000000000..2854254ae36f59c6a9e027fa3d31e931d09190f7
--- /dev/null
+++ b/django/web/static/web/img/symbol-defs.svg
@@ -0,0 +1,17 @@
+<svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+<defs>
+<symbol id="icon-pencil" viewBox="0 0 32 32">
+<path d="M27 0c2.761 0 5 2.239 5 5 0 1.126-0.372 2.164-1 3l-2 2-7-7 2-2c0.836-0.628 1.874-1 3-1zM2 23l-2 9 9-2 18.5-18.5-7-7-18.5 18.5zM22.362 11.362l-14 14-1.724-1.724 14-14 1.724 1.724z"></path>
+</symbol>
+<symbol id="icon-plus" viewBox="0 0 32 32">
+<path d="M31 12h-11v-11c0-0.552-0.448-1-1-1h-6c-0.552 0-1 0.448-1 1v11h-11c-0.552 0-1 0.448-1 1v6c0 0.552 0.448 1 1 1h11v11c0 0.552 0.448 1 1 1h6c0.552 0 1-0.448 1-1v-11h11c0.552 0 1-0.448 1-1v-6c0-0.552-0.448-1-1-1z"></path>
+</symbol>
+<symbol id="icon-cancel-circle" viewBox="0 0 32 32">
+<path d="M16 0c-8.837 0-16 7.163-16 16s7.163 16 16 16 16-7.163 16-16-7.163-16-16-16zM16 29c-7.18 0-13-5.82-13-13s5.82-13 13-13 13 5.82 13 13-5.82 13-13 13z"></path>
+<path d="M21 8l-5 5-5-5-3 3 5 5-5 5 3 3 5-5 5 5 3-3-5-5 5-5z"></path>
+</symbol>
+<symbol id="icon-cross" viewBox="0 0 32 32">
+<path d="M31.708 25.708c-0-0-0-0-0-0l-9.708-9.708 9.708-9.708c0-0 0-0 0-0 0.105-0.105 0.18-0.227 0.229-0.357 0.133-0.356 0.057-0.771-0.229-1.057l-4.586-4.586c-0.286-0.286-0.702-0.361-1.057-0.229-0.13 0.048-0.252 0.124-0.357 0.228 0 0-0 0-0 0l-9.708 9.708-9.708-9.708c-0-0-0-0-0-0-0.105-0.104-0.227-0.18-0.357-0.228-0.356-0.133-0.771-0.057-1.057 0.229l-4.586 4.586c-0.286 0.286-0.361 0.702-0.229 1.057 0.049 0.13 0.124 0.252 0.229 0.357 0 0 0 0 0 0l9.708 9.708-9.708 9.708c-0 0-0 0-0 0-0.104 0.105-0.18 0.227-0.229 0.357-0.133 0.355-0.057 0.771 0.229 1.057l4.586 4.586c0.286 0.286 0.702 0.361 1.057 0.229 0.13-0.049 0.252-0.124 0.357-0.229 0-0 0-0 0-0l9.708-9.708 9.708 9.708c0 0 0 0 0 0 0.105 0.105 0.227 0.18 0.357 0.229 0.356 0.133 0.771 0.057 1.057-0.229l4.586-4.586c0.286-0.286 0.362-0.702 0.229-1.057-0.049-0.13-0.124-0.252-0.229-0.357z"></path>
+</symbol>
+</defs>
+</svg>
diff --git a/django/web/templates/web/_comments.html b/django/web/templates/web/_comments.html
deleted file mode 100644
index 2671e8c5163dda0dc61455c431a494f8b31552ae..0000000000000000000000000000000000000000
--- a/django/web/templates/web/_comments.html
+++ /dev/null
@@ -1,103 +0,0 @@
-{% load i18n %}
-{% load formica %}
-
-<div class="content" data-controller="modals" data-action="keyup@window->modals#closeAll">
-    <h2 class="mt-8">
-        {% blocktranslate count counter=comments|length %}
-            Comment
-        {% plural %}
-            Comments
-        {% endblocktranslate %}
-    </h2>
-
-    {% for comment in comments %}
-        <div id="comment-{{ comment.pk }}" class="p-4">
-            <a href="#comment-{{ comment.pk }}" class="u-pull-right">#</a>
-            {{ comment.comment_title }} :
-            {% for description in comment.descriptions.all %}
-                <blockquote class="pb-10">
-                    [{{ description.language.code }}] {{ description.description }}
-                    <div class="mt-6 u-pull-right">
-                        <a class="btn btn-dark outline" href="#comment-update-{{ comment.pk }}-{{ description.pk }}">
-                            {% translate "✍️ Edit this comment" %}
-                        </a>
-                    </div>
-                </blockquote>
-
-                <div class="modal modal-large modal-animated--zoom-in" id="comment-update-{{ comment.pk }}-{{ description.pk }}">
-                    <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
-                        <span class="icon mt-2 mr-3">
-                            <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11 fa-wrapper" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="#fff" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>
-                        </span>
-                    </a>
-                    <div class="modal-content" role="document">
-                        <form action="{#% url 'web:comment-update' passage_pk %#}" method="post">
-                            <div class="modal-header">
-                                <div class="modal-title">
-                                    {% translate "Update comment" %}
-                                </div>
-                            </div>
-                            <div class="modal-body">
-                                {% form "web/formica/base_form.html" comment.form %}
-                                    {% fields %}
-                                {% endform %}
-                            </div>
-                            <div class="modal-footer">
-                                <div class="form-section u-flex u-justify-space-between">
-                                    <a href="#back" class="btn u-inline-block">
-                                        {% translate "Cancel" %}
-                                    </a>
-                                    <button class="btn-info u-inline-block" type="submit">
-                                        {% translate "Submit & save" %}
-                                    </button>
-                                </div>
-                            </div>
-                        </form>
-                    </div>
-                </div>
-            {% endfor %}
-        </div>
-    {% endfor %}
-
-    <div class="modal modal-large modal-animated--zoom-in" id="comment-new">
-        <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
-            <span class="icon mt-2 mr-3">
-                <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11 fa-wrapper" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="#fff" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>
-            </span>
-        </a>
-        <div class="modal-content" role="document">
-            <form
-                action="{% url 'web:comment-add' passage_pk %}"
-                data-controller="textareas"
-                data-action="input->textareas#expand"
-                method="post">
-                <div class="modal-header">
-                    <div class="modal-title">
-                        {% translate "Add a new comment" %}
-                    </div>
-                </div>
-                <div class="modal-body">
-                    {% form "web/formica/base_form.html" comment_description_form %}
-                        {% fields %}
-                    {% endform %}
-                </div>
-                <div class="modal-footer">
-                    <div class="form-section u-flex u-justify-space-between">
-                        <a href="#back" class="btn u-inline-block">
-                            {% translate "Cancel" %}
-                        </a>
-                        <button class="btn-info u-inline-block" type="submit">
-                            {% translate "Submit & save" %}
-                        </button>
-                    </div>
-                </div>
-            </form>
-        </div>
-    </div>
-
-    <div class="u-center mt-4">
-        <a class="btn btn-dark w-60" href="#comment-new">
-            {% translate "+ Add a new comment" %}
-        </a>
-    </div>
-</div>
diff --git a/django/web/templates/web/_keywords.html b/django/web/templates/web/_keywords.html
deleted file mode 100644
index 8d47662a0cbc4a9a8852f6cda89b41bcba91ba4f..0000000000000000000000000000000000000000
--- a/django/web/templates/web/_keywords.html
+++ /dev/null
@@ -1,28 +0,0 @@
-{% load i18n %}
-
-{% if keywords %}
-    <div class="content">
-        <h2 class="mt-8">
-            {% blocktranslate count counter=keywords|length %}
-                Keyword
-            {% plural %}
-                Keywords
-            {% endblocktranslate %}
-        </h2>
-        <div class="tag-container group-tags">
-        {% for keyword in keywords %}
-            {% for description in keyword.names.all %}
-                <div class="ml-2">
-                    <div class="tag">
-                        {{ keyword.category.names.all.0.name }}
-                    </div><div class="tag tag--info">
-                        {{ description.name }}
-                    </div><div class="tag tag--dark">
-                        {{ description.language.code }}
-                    </div>
-                </div>
-            {% endfor %}
-        {% endfor %}
-        </div>
-    </div>
-{% endif %}
diff --git a/django/web/templates/web/comment/detail.html b/django/web/templates/web/comment/detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..7821518aec485ef7123452d6b3767d90adaa5096
--- /dev/null
+++ b/django/web/templates/web/comment/detail.html
@@ -0,0 +1,12 @@
+{% extends "web/base.html" %}
+
+{% block content %}
+<h2>Descriptions</h2>
+<ul>
+    {% for desc in comment.descriptions.all %}
+    <li>
+        {{ desc.description }} ({{ desc.language }})</a>
+    </li>
+    {% endfor %}
+</ul>
+{% endblock %}
\ No newline at end of file
diff --git a/django/web/templates/web/index.html b/django/web/templates/web/index.html
index 74773974b01271a8c22d2d41fb5fabed9befa65a..51d3e64084102387590f511fa07decdeb497b19d 100644
--- a/django/web/templates/web/index.html
+++ b/django/web/templates/web/index.html
@@ -1,13 +1,17 @@
 {% extends "web/base.html" %}
 
 {% block content %}
-<div class="container columns">
+<div class="grid grid-cols-2 grid-gap-6">
     {% for book in books|dictsort:'number' %}
-    <h2>Livre {{ book.number }}</h2>
-    {% for passage in book.passages.all|dictsort:'fragment' %}
-    <a href="{% url 'web:passage-detail' book.number passage.fragment passage.sub_fragment %}">Epigramma
-        {{book.number}}.{{passage.fragment}}{{passage.sub_fragment}}</a><br>
-    {%endfor%}
+    <div class="mx-4">
+        <details>
+            <summary>Livre {{ book.number }}</summary>
+            {% for passage in book.passages.all|dictsort:'fragment' %}
+                <a href="{% url 'web:passage-detail' book.number passage.fragment passage.sub_fragment %}">Epigramma
+                {{book.number}}.{{passage.fragment}}{{passage.sub_fragment}}</a><br>
+            {%endfor%}
+        </details>
+    </div>
     {% endfor %}
 </div>
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/django/web/templates/web/passage.html b/django/web/templates/web/passage.html
deleted file mode 100644
index 87b13492f8e7c1c3d87812aabed3a4ca981c4714..0000000000000000000000000000000000000000
--- a/django/web/templates/web/passage.html
+++ /dev/null
@@ -1,47 +0,0 @@
-{% extends "web/base.html" %}
-{% load static i18n %}
-
-{% block content %}
-    <h1 class="mt-6 u-center headline-4 font-alt">
-        {{ passage }}
-    </h1>
-
-    {% include "web/_metadata.html" with passage=passage only %}
-    {% include "web/_manuscripts.html" with manuscripts=passage.manuscripts.all only %}
-    {% include "web/_texts.html" with texts=texts text_form=text_form passage_pk=passage.pk authors=passage.authors.all csrf_token=csrf_token only %}
-    {% include "web/_keywords.html" with texts=texts keywords=passage.keywords.all only %}
-    {% include "web/_scholia.html" with scholia=passage.mlgr_scholia.all passage=passage only %}
-    {% include "web/_comments.html" with comments=comments comment_description_form=comment_description_form passage_pk=passage.pk csrf_token=csrf_token only %}
-    {% include "web/_alignments.html" with texts=texts only %}
-{% endblock %}
-
-{% block footer %}
-    <div class="content">
-        <div class="btn-group btn-group-fill">
-            <a class="btn bg-orange-200 text-orange-700" href="{{ passage.get_previous_by_created_at.get_absolute_url }}">
-                {% translate "← Previous passage" %}
-            </a>
-            <a class="btn bg-orange-200 text-orange-700" href="{% url 'web:passage-update' passage.pk %}">
-                {% translate "Edit this passage" %}
-            </a>
-            <a class="btn bg-orange-200 text-orange-700" href="{{ passage.get_next_by_created_at.get_absolute_url }}">
-                {% translate "Next passage →" %}
-            </a>
-        </div>
-    </div>
-{% endblock %}
-
-{% block extra_body %}
-    <script src="{% static 'web/js/tabs_controller.js' %}"></script>
-    <script src="{% static 'web/js/alignments_controller.js' %}"></script>
-    <script src="{% static 'web/js/modals_controller.js' %}"></script>
-    <script src="{% static 'web/js/textareas_controller.js' %}"></script>
-    <script type="text/javascript">
-        ;(() => {
-            application.register('tabs', Tabs)
-            application.register('alignments', Alignments)
-            application.register('modals', Modals)
-            application.register('textareas', Textareas)
-        })()
-    </script>
-{% endblock %}
diff --git a/django/web/templates/web/_alignments.html b/django/web/templates/web/passage/_alignments.html
similarity index 100%
rename from django/web/templates/web/_alignments.html
rename to django/web/templates/web/passage/_alignments.html
diff --git a/django/web/templates/web/passage/_authors.html b/django/web/templates/web/passage/_authors.html
new file mode 100644
index 0000000000000000000000000000000000000000..b31bb0ec51161e2b3fff13a8fc444e0a178f0c28
--- /dev/null
+++ b/django/web/templates/web/passage/_authors.html
@@ -0,0 +1,137 @@
+{% load i18n static %}
+{% load formica %}
+
+<div id="authors" class="py-2">
+    <div class="content">
+        <h2 class="tag-container-expand mt-4 mb-2 u-flex u-items-flex-end u-justify-space-between pb-1">
+            {% blocktranslate count counter=authors|length %}
+                Author
+            {% plural %}
+                Authors
+            {% endblocktranslate %}
+            <a class="text-light" href="#author-new">
+                <span class="tag tag--halflarge tag--update tag--info bg-gray-700"> 
+                    <svg class="svgicon svgicon-plus">
+                        <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-plus"></use>
+                    </svg> 
+                </span><span class="tag tag--halflarge tag--hover tag--info bg-gray-700">
+                    {% translate 'Add' %}
+                </span>
+            </a>
+        </h2>
+        <div class="modal modal-large modal-animated--zoom-in" id="author-new">
+            <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+                <span class="icon mt-2 mr-3">
+                    <svg class="svgicon svgicon-cross">
+                        <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cross"></use>
+                    </svg>
+                </span>
+            </a>
+
+            <div class="modal-content" role="document">
+                <form
+                    action="{% url 'web:passage-add-author' passage_pk %}"
+                    method="post">
+                    <div class="modal-header">
+                        <div class="modal-title">
+                            {% translate "Add an author" %}
+                        </div>
+                    </div>
+                    <div class="modal-body">
+                        {% form "web/formica/base_form.html" add_author_to_passage_form %}
+                            {% fields %}
+                        {% endform %}
+                    </div>
+                    <div class="modal-footer">
+                        <div class="form-section u-flex u-justify-space-between">
+                            <a href="#back" class="btn u-inline-block">
+                                {% translate "Cancel" %}
+                            </a>
+                            <button class="btn-info u-inline-block" type="submit">
+                                {% translate "Submit & save" %}
+                            </button>
+                        </div>
+                    </div>
+                </form>
+            </div>
+        </div>
+
+        <div class="tag-container group-tags">
+            {% for author in authors %}
+                <p class="i18n-item tag-container-expand px-1 pt-1">
+                    {% for preferred_language in preferred_languages %}
+                        {% for name in author.names.all %}
+                            {% if name.language.code == preferred_language.code %}
+                                <span class="mr-1">
+                                    <span class="tag tag--halflarge tag--white bg-orange-300">
+                                        {{ name.name }}
+                                    </span><span class="tag tag--halflarge tag--black bg-orange-800">
+                                        {{ name.language.code }}
+                                    </span>
+                                </span>
+                            {% endif %}
+                        {% endfor %}
+                    {% endfor %}
+                    <a class="u-pull-right" href="#author-delete-{{ author.pk }}">
+                        <span class="tag tag--halflarge tag--delete tag--danger"> 
+                            <svg class="svgicon svgicon-cancel-circle">
+                                <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cancel-circle"></use>
+                            </svg> 
+                        </span><span class="tag tag--halflarge tag--hover tag--danger">
+                            {% translate 'Remove' %}
+                        </span>
+                    </a>
+                </p>
+
+                <div class="modal modal-large modal-animated--zoom-in" id="author-delete-{{ author.pk }}">
+                    <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+                        <span class="icon mt-2 mr-3">
+                            <svg class="svgicon svgicon-cross">
+                                <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cross"></use>
+                            </svg>
+                        </span>
+                    </a>
+
+                    <div class="modal-content" role="document">
+                        <form
+                            action="{% url 'web:passage-remove-author' passage_pk %}"
+                            method="post">
+                            <div class="modal-header">
+                                <div class="modal-title">
+                                    {% translate "Remove an author" %}
+                                </div>
+                            </div>
+                            <div class="modal-body">
+                                <p>
+                                    {% translate "You are about to remove an author from this passage:" %}
+                                </p>
+                                <ul>
+                                    {% for preferred_language in preferred_languages %}
+                                        {% for name in author.names.all %}
+                                            {% if name.language.code == preferred_language.code %}
+                                                <li><strong>{{ name.name }}</strong> ({{ name.language.code }})</li>
+                                            {% endif %}
+                                        {% endfor %}
+                                    {% endfor %}
+                                </ul>
+                                {% form "web/formica/base_form.html" author.remove_form %}
+                                    {% fields %}
+                                {% endform %}
+                            </div>
+                            <div class="modal-footer">
+                                <div class="form-section u-flex u-justify-space-between">
+                                    <a href="#back" class="btn u-inline-block">
+                                        {% translate "Keep it" %}
+                                    </a>
+                                    <button class="btn-info u-inline-block" type="submit">
+                                        {% translate "Confirm & remove" %}
+                                    </button>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            {% endfor %}
+        </div>
+    </div>
+</div>
diff --git a/django/web/templates/web/passage/_cities.html b/django/web/templates/web/passage/_cities.html
new file mode 100644
index 0000000000000000000000000000000000000000..45960c52d5a9d711c86322e8ec01d74503472f86
--- /dev/null
+++ b/django/web/templates/web/passage/_cities.html
@@ -0,0 +1,137 @@
+{% load i18n static %}
+{% load formica %}
+
+<div id="cities" class="py-2">
+    <div class="content">
+        <h2 class="tag-container-expand mt-4 mb-2 u-flex u-items-flex-end u-justify-space-between pb-1">
+            {% blocktranslate count counter=cities|length %}
+                City
+            {% plural %}
+                Cities
+            {% endblocktranslate %}
+            <a class="text-light" href="#city-new">
+                <span class="tag tag--halflarge tag--update tag--info bg-gray-700"> 
+                    <svg class="svgicon svgicon-plus">
+                        <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-plus"></use>
+                    </svg> 
+                </span><span class="tag tag--halflarge tag--hover tag--info bg-gray-700">
+                    {% translate 'Add' %}
+                </span>
+            </a>
+        </h2>
+        <div class="modal modal-large modal-animated--zoom-in" id="city-new">
+            <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+                <span class="icon mt-2 mr-3">
+                    <svg class="svgicon svgicon-cross">
+                        <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cross"></use>
+                    </svg>
+                </span>
+            </a>
+
+            <div class="modal-content" role="document">
+                <form
+                    action="{% url 'web:passage-add-city' passage_pk %}"
+                    method="post">
+                    <div class="modal-header">
+                        <div class="modal-title">
+                            {% translate "Add a city" %}
+                        </div>
+                    </div>
+                    <div class="modal-body">
+                        {% form "web/formica/base_form.html" add_city_to_passage_form %}
+                            {% fields %}
+                        {% endform %}
+                    </div>
+                    <div class="modal-footer">
+                        <div class="form-section u-flex u-justify-space-between">
+                            <a href="#back" class="btn u-inline-block">
+                                {% translate "Cancel" %}
+                            </a>
+                            <button class="btn-info u-inline-block" type="submit">
+                                {% translate "Submit & save" %}
+                            </button>
+                        </div>
+                    </div>
+                </form>
+            </div>
+        </div>
+
+        <div class="tag-container group-tags">
+            {% for city in cities %}
+                <p class="i18n-item tag-container-expand px-1 pt-1">
+                    {% for preferred_language in preferred_languages %}
+                        {% for name in city.names.all %}
+                            {% if name.language.code == preferred_language.code %}
+                                <span class="mr-1">
+                                    <span class="tag tag--halflarge tag--white bg-orange-300">
+                                        {{ name.name }}
+                                    </span><span class="tag tag--halflarge tag--black bg-orange-800">
+                                        {{ name.language.code }}
+                                    </span>
+                                </span>
+                            {% endif %}
+                        {% endfor %}
+                    {% endfor %}
+                    <a class="u-pull-right" href="#city-delete-{{ city.pk }}">
+                        <span class="tag tag--halflarge tag--delete tag--danger"> 
+                            <svg class="svgicon svgicon-cancel-circle">
+                                <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cancel-circle"></use>
+                            </svg> 
+                        </span><span class="tag tag--halflarge tag--hover tag--danger">
+                            {% translate 'Remove' %}
+                        </span>
+                    </a>
+                </p>
+
+                <div class="modal modal-large modal-animated--zoom-in" id="city-delete-{{ city.pk }}">
+                    <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+                        <span class="icon mt-2 mr-3">
+                            <svg class="svgicon svgicon-cross">
+                                <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cross"></use>
+                            </svg>
+                        </span>
+                    </a>
+
+                    <div class="modal-content" role="document">
+                        <form
+                            action="{% url 'web:passage-remove-city' passage_pk %}"
+                            method="post">
+                            <div class="modal-header">
+                                <div class="modal-title">
+                                    {% translate "Remove a city" %}
+                                </div>
+                            </div>
+                            <div class="modal-body">
+                                <p>
+                                    {% translate "You are about to remove a city from this passage:" %}
+                                </p>
+                                <ul>
+                                    {% for preferred_language in preferred_languages %}
+                                        {% for name in city.names.all %}
+                                            {% if name.language.code == preferred_language.code %}
+                                                <li><strong>{{ name.name }}</strong> ({{ name.language.code }})</li>
+                                            {% endif %}
+                                        {% endfor %}
+                                    {% endfor %}
+                                </ul>
+                                {% form "web/formica/base_form.html" city.remove_form %}
+                                    {% fields %}
+                                {% endform %}
+                            </div>
+                            <div class="modal-footer">
+                                <div class="form-section u-flex u-justify-space-between">
+                                    <a href="#back" class="btn u-inline-block">
+                                        {% translate "Keep it" %}
+                                    </a>
+                                    <button class="btn-info u-inline-block" type="submit">
+                                        {% translate "Confirm & remove" %}
+                                    </button>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            {% endfor %}
+        </div>
+    </div>
+</div>
diff --git a/django/web/templates/web/passage/_comments.html b/django/web/templates/web/passage/_comments.html
new file mode 100644
index 0000000000000000000000000000000000000000..3041ce245f40b495a156fab456e8a51aa1820cbd
--- /dev/null
+++ b/django/web/templates/web/passage/_comments.html
@@ -0,0 +1,159 @@
+{% load i18n static %}
+{% load formica %}
+
+<div id="comments" class="content" data-controller="modals" data-action="keyup@window->modals#closeAll">
+    <h2 class="tag-container-expand mt-4 mb-2 u-flex u-items-flex-end u-justify-space-between pb-1">
+        {% blocktranslate count counter=comments|length %}
+            Comment
+        {% plural %}
+            Comments
+        {% endblocktranslate %}
+        <a class="text-light" href="#comment-create">
+            <span class="tag tag--halflarge tag--update tag--info bg-gray-700"> 
+                <svg class="svgicon svgicon-plus">
+                    <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-plus"></use>
+                </svg> 
+            </span><span class="tag tag--halflarge tag--hover tag--info bg-gray-700">
+                {% translate 'Add' %}
+            </span>
+        </a>
+    </h2>
+
+    {% for comment in comments %}
+        <div id="comment-{{ comment.pk }}" class="p-4">
+            <a href="#comment-{{ comment.pk }}" class="u-pull-right mr-2 lead">#{{ forloop.counter }}</a>
+            {# TODO: handle multiple descriptions? #}
+            {% with description=comment.descriptions.first %}
+                <blockquote class="pb-10">
+                    [{{ description.language.code }}] {{ description.description }}
+                    <div class="mt-6 u-pull-right tag-container-expand">
+                        <a href="#comment-update-{{ comment.pk }}-{{ description.pk }}">
+                            <span class="tag tag--halflarge tag--update tag--info bg-gray-700"> 
+                                <svg class="svgicon svgicon-pencil">
+                                    <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-pencil"></use>
+                                </svg> 
+                            </span><span class="tag tag--halflarge tag--hover tag--info bg-gray-700">
+                                {% translate 'Edit' %}
+                            </span>
+                        </a>
+                        <a href="#comment-delete-{{ comment.pk }}-{{ description.pk }}">
+                            <span class="tag tag--halflarge tag--delete tag--danger"> 
+                                <svg class="svgicon svgicon-cancel-circle">
+                                    <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cancel-circle"></use>
+                                </svg> 
+                            </span><span class="tag tag--halflarge tag--hover tag--danger">
+                                {% translate 'Remove' %}
+                            </span>
+                        </a>
+                    </div>
+                </blockquote>
+
+                <div class="modal modal-large modal-animated--zoom-in" id="comment-update-{{ comment.pk }}-{{ description.pk }}">
+                    <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+                        <span class="icon mt-2 mr-3">
+                            <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11 fa-wrapper" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="#fff" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>
+                        </span>
+                    </a>
+                    <div class="modal-content" role="document">
+                        <form action="{% url 'web:comment-update' passage_pk comment.pk %}" method="post">
+                            <div class="modal-header">
+                                <div class="modal-title">
+                                    {% translate "Update comment" %}
+                                </div>
+                            </div>
+                            <div class="modal-body">
+                                {% form "web/formica/base_form.html" comment.update_form %}
+                                    {% fields %}
+                                {% endform %}
+                            </div>
+                            <div class="modal-footer">
+                                <div class="form-section u-flex u-justify-space-between">
+                                    <a href="#back" class="btn u-inline-block">
+                                        {% translate "Cancel" %}
+                                    </a>
+                                    <button class="btn-info u-inline-block" type="submit">
+                                        {% translate "Submit & save" %}
+                                    </button>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+
+                <div class="modal modal-large modal-animated--zoom-in" id="comment-delete-{{ comment.pk }}-{{ description.pk }}">
+                    <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+                        <span class="icon mt-2 mr-3">
+                            <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11 fa-wrapper" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="#fff" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>
+                        </span>
+                    </a>
+                    <div class="modal-content" role="document">
+                        <form action="{% url 'web:comment-delete' passage_pk comment.pk %}" method="post">
+                            <div class="modal-header">
+                                <div class="modal-title">
+                                    {% translate "Delete comment" %}
+                                </div>
+                            </div>
+                            <div class="modal-body">
+                                <p>
+                                    {% translate "You are about to delete a comment from this passage:" %}
+                                </p>
+                                <p>
+                                    {{ description.description }}
+                                </p>
+                                {% form "web/formica/base_form.html" comment.delete_form %}
+                                    {% fields %}
+                                {% endform %}
+                            </div>
+                            <div class="modal-footer">
+                                <div class="form-section u-flex u-justify-space-between">
+                                    <a href="#back" class="btn u-inline-block">
+                                        {% translate "Cancel" %}
+                                    </a>
+                                    <button class="btn-info u-inline-block" type="submit">
+                                        {% translate "Submit & save" %}
+                                    </button>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            {% endwith %}
+        </div>
+    {% endfor %}
+
+    <div class="modal modal-large modal-animated--zoom-in" id="comment-create">
+        <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+            <span class="icon mt-2 mr-3">
+                <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11 fa-wrapper" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="#fff" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>
+            </span>
+        </a>
+        <div class="modal-content" role="document">
+            <form
+                action="{% url 'web:comment-create' passage_pk %}"
+                data-controller="textareas"
+                data-action="input->textareas#expand"
+                method="post">
+                <div class="modal-header">
+                    <div class="modal-title">
+                        {% translate "Add a new comment" %}
+                    </div>
+                </div>
+                <div class="modal-body">
+                    {% form "web/formica/base_form.html" comment_create_form %}
+                        {% fields %}
+                    {% endform %}
+                </div>
+                <div class="modal-footer">
+                    <div class="form-section u-flex u-justify-space-between">
+                        <a href="#back" class="btn u-inline-block">
+                            {% translate "Cancel" %}
+                        </a>
+                        <button class="btn-info u-inline-block" type="submit">
+                            {% translate "Submit & save" %}
+                        </button>
+                    </div>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
diff --git a/django/web/templates/web/passage/_descriptions.html b/django/web/templates/web/passage/_descriptions.html
new file mode 100644
index 0000000000000000000000000000000000000000..cbff84165c11d65afbb8ec1c80ecdab7a0c615d5
--- /dev/null
+++ b/django/web/templates/web/passage/_descriptions.html
@@ -0,0 +1,156 @@
+{% load i18n static %}
+{% load formica %}
+
+<div id="descriptions" class="content" data-controller="modals" data-action="keyup@window->modals#closeAll">
+    <h2 class="tag-container-expand mt-4 mb-2 u-flex u-items-flex-end u-justify-space-between pb-1">
+        {% blocktranslate count counter=description|length %}
+            Description
+        {% plural %}
+            Descriptions
+        {% endblocktranslate %}
+        <a class="text-light" href="#description-create">
+            <span class="tag tag--halflarge tag--update tag--info bg-gray-700"> 
+                <svg class="svgicon svgicon-plus">
+                    <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-plus"></use>
+                </svg> 
+            </span><span class="tag tag--halflarge tag--hover tag--info bg-gray-700">
+                {% translate 'Add' %}
+            </span>
+        </a>
+    </h2>
+
+    {% for description in descriptions %}
+        <div id="description-{{ description.pk }}" class="p-4">
+            <a href="#description-{{ description.pk }}" class="u-pull-right mr-2 lead">#{{ forloop.counter }}</a>
+            <blockquote class="pb-10">
+                [{{ description.language.code }}] {{ description.description }}
+                <div class="mt-6 u-pull-right tag-container-expand">
+                    <a href="#description-update-{{ description.pk }}">
+                        <span class="tag tag--halflarge tag--update tag--info bg-gray-700"> 
+                            <svg class="svgicon svgicon-pencil">
+                                <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-pencil"></use>
+                            </svg> 
+                        </span><span class="tag tag--halflarge tag--hover tag--info bg-gray-700">
+                            {% translate 'Edit' %}
+                        </span>
+                    </a>
+                    <a href="#description-delete-{{ description.pk }}">
+                        <span class="tag tag--halflarge tag--delete tag--danger"> 
+                            <svg class="svgicon svgicon-cancel-circle">
+                                <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cancel-circle"></use>
+                            </svg> 
+                        </span><span class="tag tag--halflarge tag--hover tag--danger">
+                            {% translate 'Remove' %}
+                        </span>
+                    </a>
+                </div>
+            </blockquote>
+
+            <div class="modal modal-large modal-animated--zoom-in" id="description-update-{{ description.pk }}">
+                <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+                    <span class="icon mt-2 mr-3">
+                        <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11 fa-wrapper" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="#fff" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>
+                    </span>
+                </a>
+                <div class="modal-content" role="document">
+                    <form action="{% url 'web:description-update' passage_pk description.pk %}" method="post">
+                        <div class="modal-header">
+                            <div class="modal-title">
+                                {% translate "Update description" %}
+                            </div>
+                        </div>
+                        <div class="modal-body">
+                            {% form "web/formica/base_form.html" description.update_form %}
+                                {% fields %}
+                            {% endform %}
+                        </div>
+                        <div class="modal-footer">
+                            <div class="form-section u-flex u-justify-space-between">
+                                <a href="#back" class="btn u-inline-block">
+                                    {% translate "Cancel" %}
+                                </a>
+                                <button class="btn-info u-inline-block" type="submit">
+                                    {% translate "Submit & save" %}
+                                </button>
+                            </div>
+                        </div>
+                    </form>
+                </div>
+            </div>
+
+            <div class="modal modal-large modal-animated--zoom-in" id="description-delete-{{ description.pk }}">
+                <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+                    <span class="icon mt-2 mr-3">
+                        <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11 fa-wrapper" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="#fff" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>
+                    </span>
+                </a>
+                <div class="modal-content" role="document">
+                    <form action="{% url 'web:description-delete' passage_pk description.pk %}" method="post">
+                        <div class="modal-header">
+                            <div class="modal-title">
+                                {% translate "Delete description" %}
+                            </div>
+                        </div>
+                        <div class="modal-body">
+                            <p>
+                                {% translate "You are about to delete a description from this passage:" %}
+                            </p>
+                            <p>
+                                {{ description.description }}
+                            </p>
+                            {% form "web/formica/base_form.html" description.delete_form %}
+                                {% fields %}
+                            {% endform %}
+                        </div>
+                        <div class="modal-footer">
+                            <div class="form-section u-flex u-justify-space-between">
+                                <a href="#back" class="btn u-inline-block">
+                                    {% translate "Cancel" %}
+                                </a>
+                                <button class="btn-info u-inline-block" type="submit">
+                                    {% translate "Submit & save" %}
+                                </button>
+                            </div>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+    {% endfor %}
+
+    <div class="modal modal-large modal-animated--zoom-in" id="description-create">
+        <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+            <span class="icon mt-2 mr-3">
+                <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11 fa-wrapper" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="#fff" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>
+            </span>
+        </a>
+        <div class="modal-content" role="document">
+            <form
+                action="{% url 'web:description-create' passage_pk %}"
+                data-controller="textareas"
+                data-action="input->textareas#expand"
+                method="post">
+                <div class="modal-header">
+                    <div class="modal-title">
+                        {% translate "Add a new description" %}
+                    </div>
+                </div>
+                <div class="modal-body">
+                    {% form "web/formica/base_form.html" description_create_form %}
+                        {% fields %}
+                    {% endform %}
+                </div>
+                <div class="modal-footer">
+                    <div class="form-section u-flex u-justify-space-between">
+                        <a href="#back" class="btn u-inline-block">
+                            {% translate "Cancel" %}
+                        </a>
+                        <button class="btn-info u-inline-block" type="submit">
+                            {% translate "Submit & save" %}
+                        </button>
+                    </div>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
diff --git a/django/web/templates/web/passage/_external_references.html b/django/web/templates/web/passage/_external_references.html
new file mode 100644
index 0000000000000000000000000000000000000000..715782ffa6942afbab800c5912a1e3d9fb5be9e3
--- /dev/null
+++ b/django/web/templates/web/passage/_external_references.html
@@ -0,0 +1,21 @@
+{% load i18n %}
+
+{% if external_references %}
+<div id="external_references" class="py-2">
+    <div class="content">
+        <h2 class="mt-4 mb-2 u-flex u-items-baseline u-justify-space-between">
+            {% blocktranslate count counter=external_references|length %}
+                External reference
+            {% plural %}
+                External references
+            {% endblocktranslate %}
+        </h2>
+        <p>
+            {% for external_reference in external_references %}
+                <a href="{{ external_reference.url }}">
+                    {{ external_reference.title }}</a>{% if not forloop.last %}, {% endif %}
+            {% endfor %}
+        </p>
+    </div>
+</div>
+{% endif %}
diff --git a/django/web/templates/web/passage/_internal_references.html b/django/web/templates/web/passage/_internal_references.html
new file mode 100644
index 0000000000000000000000000000000000000000..be7c23cb386576189ca0bd9ae23546b6e1a429bf
--- /dev/null
+++ b/django/web/templates/web/passage/_internal_references.html
@@ -0,0 +1,21 @@
+{% load i18n %}
+
+{% if internal_references %}
+<div id="internal_references" class="py-2">
+    <div class="content">
+        <h2 class="mt-4 mb-2 u-flex u-items-baseline u-justify-space-between">
+            {% blocktranslate count counter=internal_references|length %}
+                Internal reference
+            {% plural %}
+                Internal references
+            {% endblocktranslate %}
+        </h2>
+        <p>
+            {% for internal_reference in internal_references %}
+                <a href="{% url 'web:passage-detail' internal_reference.book.number internal_reference.fragment internal_reference.sub_fragment %}">
+                    {% translate "Passage" %} {{ internal_reference.book.number }}.{{ internal_reference.fragment }}{{ internal_reference.sub_fragment }}</a>{% if not forloop.last %}, {% endif %}
+            {% endfor %}
+        </p>
+    </div>
+</div>
+{% endif %}
diff --git a/django/web/templates/web/passage/_keywords.html b/django/web/templates/web/passage/_keywords.html
new file mode 100644
index 0000000000000000000000000000000000000000..3b28d1c1825db7a10095218f15c2faea1e8d0788
--- /dev/null
+++ b/django/web/templates/web/passage/_keywords.html
@@ -0,0 +1,138 @@
+{% load i18n static %}
+{% load formica %}
+
+<div id="keywords" class="py-2">
+    <div class="content">
+        <h2 class="tag-container-expand mt-4 mb-2 u-flex u-items-flex-end u-justify-space-between pb-1">
+            {% blocktranslate count counter=keywords|length %}
+                Keyword
+            {% plural %}
+                Keywords
+            {% endblocktranslate %}
+            <a class="text-light" href="#keyword-new">
+                <span class="tag tag--halflarge tag--update tag--info bg-gray-700"> 
+                    <svg class="svgicon svgicon-plus">
+                        <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-plus"></use>
+                    </svg> 
+                </span><span class="tag tag--halflarge tag--hover tag--info bg-gray-700">
+                    {% translate 'Add' %}
+                </span>
+            </a>
+        </h2>
+        <div class="modal modal-large modal-animated--zoom-in" id="keyword-new">
+            <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+                <span class="icon mt-2 mr-3">
+                    <svg class="svgicon svgicon-cross">
+                        <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cross"></use>
+                    </svg>
+                </span>
+            </a>
+
+            <div class="modal-content" role="document">
+                <form
+                    action="{% url 'web:passage-add-keyword' passage_pk %}"
+                    method="post">
+                    <div class="modal-header">
+                        <div class="modal-title">
+                            {% translate "Add a keyword" %}
+                        </div>
+                    </div>
+                    <div class="modal-body">
+                        {% form "web/formica/base_form.html" add_keyword_to_passage_form %}
+                            {% fields %}
+                        {% endform %}
+                    </div>
+                    <div class="modal-footer">
+                        <div class="form-section u-flex u-justify-space-between">
+                            <a href="#back" class="btn u-inline-block">
+                                {% translate "Cancel" %}
+                            </a>
+                            <button class="btn-info u-inline-block" type="submit">
+                                {% translate "Submit & save" %}
+                            </button>
+                        </div>
+                    </div>
+                </form>
+            </div>
+        </div>
+
+        {% for keyword_category in keywords_categories %}
+            {# Categories are only in French for now (first item). #}
+            <h5 class="font-light">{{ keyword_category.names.first.name }}</h5>
+            <div class="tag-container group-tags">
+                {% for keyword in keywords %}
+                    {% if keyword.category.pk == keyword_category.pk %}
+                        <p class="i18n-item tag-container-expand px-1 pt-1">
+                            {% for name in keyword.names.all %}
+                                <span class="mr-1">
+                                    <span class="tag tag--halflarge tag--white bg-orange-300">
+                                        {{ name.name }}
+                                    </span><span class="tag tag--halflarge tag--black bg-orange-800">
+                                        {{ name.language.code }}
+                                    </span>
+                                </span>
+                            {% endfor %}
+                            <a class="u-pull-right" href="#keyword-delete-{{ keyword.pk }}">
+                                <span class="tag tag--halflarge tag--delete tag--danger"> 
+                                    <svg class="svgicon svgicon-cancel-circle">
+                                        <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cancel-circle"></use>
+                                    </svg> 
+                                </span><span class="tag tag--halflarge tag--hover tag--danger">
+                                    {% translate 'Remove' %}
+                                </span>
+                            </a>
+                        </p>
+                    {% endif %}
+                {% endfor %}
+                {% for keyword in keywords %}
+                    {% if keyword.category.pk == keyword_category.pk %}
+                        <div class="modal modal-large modal-animated--zoom-in" id="keyword-delete-{{ keyword.pk }}">
+                            <a href="#back" data-target="modals.closebtn" class="modal-overlay close-btn u-text-right" aria-label="{% translate 'Close' %}">
+                                <span class="icon mt-2 mr-3">
+                                    <svg class="svgicon svgicon-cross">
+                                        <use xlink:href="{% static 'web/img/symbol-defs.svg' %}#icon-cross"></use>
+                                    </svg>
+                                </span>
+                            </a>
+
+                            <div class="modal-content" role="document">
+                                <form
+                                    action="{% url 'web:passage-remove-keyword' passage_pk %}"
+                                    method="post">
+                                    <div class="modal-header">
+                                        <div class="modal-title">
+                                            {% translate "Remove a keyword" %}
+                                        </div>
+                                    </div>
+                                    <div class="modal-body">
+                                        <p>
+                                            {% translate "You are about to remove a keyword from this passage:" %}
+                                        </p>
+                                        <ul>
+                                            {% for name in keyword.names.all %}
+                                                <li><strong>{{ name.name }}</strong> ({{ name.language.code }})</li>
+                                            {% endfor %}
+                                        </ul>
+                                        {% form "web/formica/base_form.html" keyword.remove_form %}
+                                            {% fields %}
+                                        {% endform %}
+                                    </div>
+                                    <div class="modal-footer">
+                                        <div class="form-section u-flex u-justify-space-between">
+                                            <a href="#back" class="btn u-inline-block">
+                                                {% translate "Keep it" %}
+                                            </a>
+                                            <button class="btn-info u-inline-block" type="submit">
+                                                {% translate "Confirm & remove" %}
+                                            </button>
+                                        </div>
+                                    </div>
+                                </form>
+                            </div>
+                        </div>
+                    {% endif %}
+                {% endfor %}
+            </div>
+        {% endfor %}
+    </div>
+</div>
diff --git a/django/web/templates/web/_manuscripts.html b/django/web/templates/web/passage/_manuscripts.html
similarity index 100%
rename from django/web/templates/web/_manuscripts.html
rename to django/web/templates/web/passage/_manuscripts.html
diff --git a/django/web/templates/web/_metadata.html b/django/web/templates/web/passage/_metadata.html
similarity index 50%
rename from django/web/templates/web/_metadata.html
rename to django/web/templates/web/passage/_metadata.html
index aa6ed3b60e9c05e89f843136be9e1d5028fd49bb..494af588f2bac847e055cd4901f069954e0c4d1a 100644
--- a/django/web/templates/web/_metadata.html
+++ b/django/web/templates/web/passage/_metadata.html
@@ -1,19 +1,21 @@
 {% load i18n %}
 
 <div class="tag-container group-tags u-center">
-    <div>
-        <div class="tag">{% translate "URN" %}</div><div class="tag tag--link">{{ passage.urn_value }}</div>
-    </div>
+    {% if obj.urn_value %}
+        <div>
+            <div class="tag">{% translate "URN" %}</div><div class="tag tag--link">{{ obj.urn_value }}</div>
+        </div>
+    {% endif %}
 
     <div class="ml-2">
-        <div class="tag">{% translate "Created on" %}</div><div class="tag tag--link">{{ passage.created_at|date:"F j, Y" }}</div>
+        <div class="tag">{% translate "Created on" %}</div><div class="tag tag--link">{{ obj.created_at|date:"F j, Y" }}</div>
     </div>
 
     <div class="ml-2">
-        <div class="tag">{% translate "By" %}</div><div class="tag tag--link">{{ passage.creator }}</div>
+        <div class="tag">{% translate "By" %}</div><div class="tag tag--link">{{ obj.creator }}</div>
     </div>
 
     <div class="ml-2">
-        <div class="tag">{% translate "Updated on" %}</div><div class="tag tag--link">{{ passage.updated_at|date:"F j, Y" }}</div>
+        <div class="tag">{% translate "Updated on" %}</div><div class="tag tag--link">{{ obj.updated_at|date:"F j, Y" }}</div>
     </div>
 </div>
diff --git a/django/web/templates/web/_scholia.html b/django/web/templates/web/passage/_scholia.html
similarity index 100%
rename from django/web/templates/web/_scholia.html
rename to django/web/templates/web/passage/_scholia.html
diff --git a/django/web/templates/web/_texts.html b/django/web/templates/web/passage/_texts.html
similarity index 80%
rename from django/web/templates/web/_texts.html
rename to django/web/templates/web/passage/_texts.html
index 911af23a6dfcbe5afb6f793ee0154b622062bd60..f023289e030a4a3491ce9196e4eb49a2713761c5 100644
--- a/django/web/templates/web/_texts.html
+++ b/django/web/templates/web/passage/_texts.html
@@ -9,7 +9,7 @@
                     <ul role="tablist"
                         data-action="keydown->tabs#keyboardA11Y"
                         aria-label="{% translate 'Texts translations tabs (first)' %}">
-                        {% for text in texts reversed %}
+                        {% for text in texts %}
                             <li {% if forloop.first %}class="selected"{% endif %}>
                                 <a role="tab"
                                     id="text-tab-first-{{ forloop.counter }}-{{ text.language.code }}"
@@ -22,11 +22,11 @@
                                     >{{ text.language.code }}</a>
                             </li>
                         {% endfor %}
-                        <li><a role="tab" href="#text-new">{% translate '+ add' %}</a></li>
+                        <li><a class="bg-gray-700 text-light" role="tab" href="#text-new">{% translate '+ add' %}</a></li>
                     </ul>
                 </div>
 
-                {% for text in texts reversed %}
+                {% for text in texts %}
                     <div role="tabpanel"
                         id="text-content-first-{{ forloop.counter }}-{{ text.language.code }}"
                         aria-labelledby="text-tab-first-{{ forloop.counter }}-{{ text.language.code }}"
@@ -36,15 +36,11 @@
                         >
                         <blockquote>
                             {{ text.text|linebreaks }}
-                            {% for author in authors %}
-                                {% for name in author.names.all %}
-                                    {% if name.language.code == text.language.code %}
-                                        — <a href="{% url 'web:author-detail' author.pk %}">
-                                            {{ name.name }}
-                                        </a>
-                                    {% endif %}
-                                {% endfor %}
-                            {% endfor %}
+                            {% if text.author %}
+                                — <a href="{% url 'web:author-detail' text.author.pk %}">
+                                    {{ text.author_name.name }}
+                                </a>
+                            {% endif %}
                         </blockquote>
                     </div>
                 {% endfor %}
@@ -56,7 +52,7 @@
                     <ul role="tablist"
                         data-action="keydown->tabs#keyboardA11Y"
                         aria-label="{% translate 'Texts translations tabs (last)' %}">
-                        {% for text in texts %}
+                        {% for text in texts reversed %}
                             <li {% if forloop.first %}class="selected"{% endif %}>
                                 <a role="tab"
                                     id="text-tab-last-{{ forloop.counter }}-{{ text.language.code }}"
@@ -69,11 +65,11 @@
                                     >{{ text.language.code }}</a>
                             </li>
                         {% endfor %}
-                        <li><a role="tab" href="#text-new">{% translate '+ add' %}</a></li>
+                        <li><a class="bg-gray-700 text-light" role="tab" href="#text-new">{% translate '+ add' %}</a></li>
                     </ul>
                 </div>
 
-                {% for text in texts %}
+                {% for text in texts reversed %}
                     <div role="tabpanel"
                         id="text-content-last-{{ forloop.counter }}-{{ text.language.code }}"
                         aria-labelledby="text-tab-last-{{ forloop.counter }}-{{ text.language.code }}"
@@ -83,15 +79,11 @@
                         >
                         <blockquote>
                             {{ text.text|linebreaks }}
-                            {% for author in authors %}
-                                {% for name in author.names.all %}
-                                    {% if name.language.code == text.language.code %}
-                                        — <a href="{% url 'web:author-detail' author.pk %}">
-                                            {{ name.name }}
-                                        </a>
-                                    {% endif %}
-                                {% endfor %}
-                            {% endfor %}
+                            {% if text.author %}
+                                — <a href="{% url 'web:author-detail' text.author.pk %}">
+                                    {{ text.author_name.name }}
+                                </a>
+                            {% endif %}
                         </blockquote>
                     </div>
                 {% endfor %}
@@ -116,7 +108,7 @@
                     </div>
                 </div>
                 <div class="modal-body">
-                    {% form "web/formica/base_form.html" text_form %}
+                    {% form "web/formica/base_form.html" add_text_to_passage_form %}
                         {% fields %}
                     {% endform %}
                 </div>
diff --git a/django/web/templates/web/passage/add_author_to_passage.html b/django/web/templates/web/passage/add_author_to_passage.html
new file mode 100644
index 0000000000000000000000000000000000000000..86518ee63025568a9608af7bc1e50a3c4702b0e6
--- /dev/null
+++ b/django/web/templates/web/passage/add_author_to_passage.html
@@ -0,0 +1,28 @@
+{% extends "web/base.html" %}
+{% load static i18n %}
+{% load formica %}
+
+{% block content %}
+    <h1 class="mt-6 u-center headline-4 font-alt">
+        {{ passage }}
+    </h1>
+
+    <div class="content">
+        <form
+            action="."
+            method="post">
+            {% form "web/formica/base_form.html" add_author_to_passage_form %}
+                {% fields %}
+            {% endform %}
+            <div class="form-section u-flex u-justify-space-between">
+                <a href="{{ passage.get_absolute_url }}" class="btn u-inline-block">
+                    {% translate "Cancel" %}
+                </a>
+                <button class="btn-info u-inline-block" type="submit">
+                    {% translate "Submit & save" %}
+                </button>
+            </div>
+        </form>
+    </div>
+
+{% endblock %}
diff --git a/django/web/templates/web/passage/add_city_to_passage.html b/django/web/templates/web/passage/add_city_to_passage.html
new file mode 100644
index 0000000000000000000000000000000000000000..6ccafec01f7fb6553fbdd34cf3cea030e1ee4073
--- /dev/null
+++ b/django/web/templates/web/passage/add_city_to_passage.html
@@ -0,0 +1,29 @@
+{% extends "web/base.html" %}
+{% load static i18n %}
+{% load formica %}
+
+{% block content %}
+    <h1 class="mt-6 u-center headline-4 font-alt">
+        {{ passage }}
+    </h1>
+
+    <div class="content">
+        <form
+            action="."
+            method="post">
+            {% form "web/formica/base_form.html" add_city_to_passage_form %}
+                {% fields %}
+            {% endform %}
+            <div class="form-section u-flex u-justify-space-between">
+                <a href="{{ passage.get_absolute_url }}" class="btn u-inline-block">
+                    {% translate "Cancel" %}
+                </a>
+                <button class="btn-info u-inline-block" type="submit">
+                    {% translate "Submit & save" %}
+                </button>
+            </div>
+        </form>
+    </div>
+
+{% endblock %}
+
diff --git a/django/web/templates/web/passage/add_keyword_to_passage.html b/django/web/templates/web/passage/add_keyword_to_passage.html
new file mode 100644
index 0000000000000000000000000000000000000000..de43ad05510f70f8cf71c6e378425e0a55b34b14
--- /dev/null
+++ b/django/web/templates/web/passage/add_keyword_to_passage.html
@@ -0,0 +1,29 @@
+{% extends "web/base.html" %}
+{% load static i18n %}
+{% load formica %}
+
+{% block content %}
+    <h1 class="mt-6 u-center headline-4 font-alt">
+        {{ passage }}
+    </h1>
+
+    <div class="content">
+        <form
+            action="."
+            method="post">
+            {% form "web/formica/base_form.html" add_keyword_to_passage_form %}
+                {% fields %}
+            {% endform %}
+            <div class="form-section u-flex u-justify-space-between">
+                <a href="{{ passage.get_absolute_url }}" class="btn u-inline-block">
+                    {% translate "Cancel" %}
+                </a>
+                <button class="btn-info u-inline-block" type="submit">
+                    {% translate "Submit & save" %}
+                </button>
+            </div>
+        </form>
+    </div>
+
+{% endblock %}
+
diff --git a/django/web/templates/web/add_text_from_passage.html b/django/web/templates/web/passage/add_text_to_passage.html
similarity index 93%
rename from django/web/templates/web/add_text_from_passage.html
rename to django/web/templates/web/passage/add_text_to_passage.html
index b195888b9a0022864ae6751088e5c7b0697ad247..8035894fa6d19df2df42f60caf9321286d7be4b7 100644
--- a/django/web/templates/web/add_text_from_passage.html
+++ b/django/web/templates/web/passage/add_text_to_passage.html
@@ -13,7 +13,7 @@
             data-controller="textareas"
             data-action="input->textareas#expand"
             method="post">
-            {% form "web/formica/base_form.html" text_form %}
+            {% form "web/formica/base_form.html" add_text_to_passage_form %}
                 {% fields %}
             {% endform %}
             <div class="form-section u-flex u-justify-space-between">
diff --git a/django/web/templates/web/add_comment_from_passage.html b/django/web/templates/web/passage/comment_create.html
similarity index 93%
rename from django/web/templates/web/add_comment_from_passage.html
rename to django/web/templates/web/passage/comment_create.html
index 082f938dc4201f2bbccc0d1c860d3bb24e7552e0..8b82d8447fc038a297f747187c95adb845ae7914 100644
--- a/django/web/templates/web/add_comment_from_passage.html
+++ b/django/web/templates/web/passage/comment_create.html
@@ -14,7 +14,7 @@
             data-action="input->textareas#expand"
             method="post">
             {% csrf_token %}
-            {% form "web/formica/base_form.html" comment_description_form %}
+            {% form "web/formica/base_form.html" comment_create_form %}
                 {% fields %}
             {% endform %}
             <div class="form-section u-flex u-justify-space-between">
diff --git a/django/web/templates/web/passage/comment_delete.html b/django/web/templates/web/passage/comment_delete.html
new file mode 100644
index 0000000000000000000000000000000000000000..e597d3ffa9c8bebbee46ba6a921127376739fe63
--- /dev/null
+++ b/django/web/templates/web/passage/comment_delete.html
@@ -0,0 +1,40 @@
+{% extends "web/base.html" %}
+{% load static i18n %}
+{% load formica %}
+
+{% block content %}
+    <h1 class="mt-6 u-center headline-4 font-alt">
+        {{ passage }}
+    </h1>
+
+    <div class="content">
+        <form
+            action="."
+            data-controller="textareas"
+            data-action="input->textareas#expand"
+            method="post">
+            {% csrf_token %}
+            {% form "web/formica/base_form.html" comment_delete_form %}
+                {% fields %}
+            {% endform %}
+            <div class="form-section u-flex u-justify-space-between">
+                <a href="{{ passage.get_absolute_url }}" class="btn u-inline-block">
+                    {% translate "Cancel" %}
+                </a>
+                <button class="btn-info u-inline-block" type="submit">
+                    {% translate "Submit & save" %}
+                </button>
+            </div>
+        </form>
+    </div>
+
+{% endblock %}
+
+{% block extra_body %}
+    <script src="{% static 'web/js/textareas_controller.js' %}"></script>
+    <script type="text/javascript">
+        ;(() => {
+            application.register('textareas', Textareas)
+        })()
+    </script>
+{% endblock %}
diff --git a/django/web/templates/web/passage/comment_update.html b/django/web/templates/web/passage/comment_update.html
new file mode 100644
index 0000000000000000000000000000000000000000..d7de1ecaac31fea0b9c5e9d89d4cb3255c469fab
--- /dev/null
+++ b/django/web/templates/web/passage/comment_update.html
@@ -0,0 +1,40 @@
+{% extends "web/base.html" %}
+{% load static i18n %}
+{% load formica %}
+
+{% block content %}
+    <h1 class="mt-6 u-center headline-4 font-alt">
+        {{ passage }}
+    </h1>
+
+    <div class="content">
+        <form
+            action="."
+            data-controller="textareas"
+            data-action="input->textareas#expand"
+            method="post">
+            {% csrf_token %}
+            {% form "web/formica/base_form.html" comment_update_form %}
+                {% fields %}
+            {% endform %}
+            <div class="form-section u-flex u-justify-space-between">
+                <a href="{{ passage.get_absolute_url }}" class="btn u-inline-block">
+                    {% translate "Cancel" %}
+                </a>
+                <button class="btn-info u-inline-block" type="submit">
+                    {% translate "Submit & save" %}
+                </button>
+            </div>
+        </form>
+    </div>
+
+{% endblock %}
+
+{% block extra_body %}
+    <script src="{% static 'web/js/textareas_controller.js' %}"></script>
+    <script type="text/javascript">
+        ;(() => {
+            application.register('textareas', Textareas)
+        })()
+    </script>
+{% endblock %}
diff --git a/django/web/templates/web/passage/passage.html b/django/web/templates/web/passage/passage.html
new file mode 100644
index 0000000000000000000000000000000000000000..433198e9211daffbd2208e6f5dddeba487586b7f
--- /dev/null
+++ b/django/web/templates/web/passage/passage.html
@@ -0,0 +1,52 @@
+{% extends "web/base.html" %}
+{% load static i18n %}
+
+{% block content %}
+    <h1 class="mt-6 u-center headline-4 font-alt">
+        {% translate "Passage" %} {{ book.number }}.{{ passage.fragment }}{{ passage.sub_fragment }}
+    </h1>
+
+    {% include "web/passage/_metadata.html" with obj=passage only %}
+    {% include "web/passage/_descriptions.html" with descriptions=descriptions description_create_form=description_create_form passage_pk=passage.pk csrf_token=csrf_token only %}
+    {% include "web/passage/_manuscripts.html" with manuscripts=passage.manuscripts.all only %}
+    {% include "web/passage/_texts.html" with texts=texts add_text_to_passage_form=add_text_to_passage_form passage_pk=passage.pk csrf_token=csrf_token only %}
+    {% include "web/passage/_authors.html" with authors=authors add_author_to_passage_form=add_author_to_passage_form passage_pk=passage.pk preferred_languages=preferred_languages csrf_token=csrf_token only %}
+    {% include "web/passage/_cities.html" with cities=cities add_city_to_passage_form=add_city_to_passage_form passage_pk=passage.pk preferred_languages=preferred_languages csrf_token=csrf_token only %}
+    {% include "web/passage/_keywords.html" with keywords=keywords keywords_categories=keywords_categories add_keyword_to_passage_form=add_keyword_to_passage_form passage_pk=passage.pk preferred_languages=preferred_languages csrf_token=csrf_token only %}
+    {% include "web/passage/_scholia.html" with scholia=passage.scholia.all passage=passage only %}
+    {% include "web/passage/_comments.html" with comments=comments comment_create_form=comment_create_form passage_pk=passage.pk csrf_token=csrf_token only %}
+    {% include "web/passage/_alignments.html" with texts=texts only %}
+    {% include "web/passage/_internal_references.html" with internal_references=passage.internal_references.all only %}
+    {% include "web/passage/_external_references.html" with external_references=passage.external_references.all only %}
+{% endblock %}
+
+{% block footer %}
+    <div class="content">
+        <div class="btn-group btn-group-fill">
+            <a class="btn bg-orange-200 text-orange-700" href="{{ passage.get_previous_by_created_at.get_absolute_url }}">
+                {% translate "← Previous passage" %}
+            </a>
+            <a class="btn bg-orange-200 text-orange-700" href="{% url 'web:passage-update' passage.pk %}">
+                {% translate "Edit this passage" %}
+            </a>
+            <a class="btn bg-orange-200 text-orange-700" href="{{ passage.get_next_by_created_at.get_absolute_url }}">
+                {% translate "Next passage →" %}
+            </a>
+        </div>
+    </div>
+{% endblock %}
+
+{% block extra_body %}
+    <script src="{% static 'web/js/tabs_controller.js' %}"></script>
+    <script src="{% static 'web/js/alignments_controller.js' %}"></script>
+    <script src="{% static 'web/js/modals_controller.js' %}"></script>
+    <script src="{% static 'web/js/textareas_controller.js' %}"></script>
+    <script type="text/javascript">
+        ;(() => {
+            application.register('tabs', Tabs)
+            application.register('alignments', Alignments)
+            application.register('modals', Modals)
+            application.register('textareas', Textareas)
+        })()
+    </script>
+{% endblock %}
diff --git a/django/web/templates/web/passage_form.html b/django/web/templates/web/passage/passage_form.html
similarity index 100%
rename from django/web/templates/web/passage_form.html
rename to django/web/templates/web/passage/passage_form.html
diff --git a/django/web/templates/web/passage/remove_author_from_passage.html b/django/web/templates/web/passage/remove_author_from_passage.html
new file mode 100644
index 0000000000000000000000000000000000000000..fa1d23b924b49630b5c7d57aa02b4c4bd3ba9c84
--- /dev/null
+++ b/django/web/templates/web/passage/remove_author_from_passage.html
@@ -0,0 +1,29 @@
+{% extends "web/base.html" %}
+{% load static i18n %}
+{% load formica %}
+
+{% block content %}
+    <h1 class="mt-6 u-center headline-4 font-alt">
+        {{ passage }}
+    </h1>
+
+    <div class="content">
+        <form
+            action="."
+            method="post">
+            {% form "web/formica/base_form.html" remove_author_from_passage_form %}
+                {% fields %}
+            {% endform %}
+            <div class="form-section u-flex u-justify-space-between">
+                <a href="{{ passage.get_absolute_url }}" class="btn u-inline-block">
+                    {% translate "Cancel" %}
+                </a>
+                <button class="btn-info u-inline-block" type="submit">
+                    {% translate "Submit & save" %}
+                </button>
+            </div>
+        </form>
+    </div>
+
+{% endblock %}
+
diff --git a/django/web/templates/web/passage/remove_city_from_passage.html b/django/web/templates/web/passage/remove_city_from_passage.html
new file mode 100644
index 0000000000000000000000000000000000000000..011da35d5b7ef1b4d3846452f5b9f5e78d43f73b
--- /dev/null
+++ b/django/web/templates/web/passage/remove_city_from_passage.html
@@ -0,0 +1,29 @@
+{% extends "web/base.html" %}
+{% load static i18n %}
+{% load formica %}
+
+{% block content %}
+    <h1 class="mt-6 u-center headline-4 font-alt">
+        {{ passage }}
+    </h1>
+
+    <div class="content">
+        <form
+            action="."
+            method="post">
+            {% form "web/formica/base_form.html" remove_city_from_passage_form %}
+                {% fields %}
+            {% endform %}
+            <div class="form-section u-flex u-justify-space-between">
+                <a href="{{ passage.get_absolute_url }}" class="btn u-inline-block">
+                    {% translate "Cancel" %}
+                </a>
+                <button class="btn-info u-inline-block" type="submit">
+                    {% translate "Submit & save" %}
+                </button>
+            </div>
+        </form>
+    </div>
+
+{% endblock %}
+
diff --git a/django/web/templates/web/passage/remove_keyword_from_passage.html b/django/web/templates/web/passage/remove_keyword_from_passage.html
new file mode 100644
index 0000000000000000000000000000000000000000..8439e111941c487205f24185c17c5f38a1ea7284
--- /dev/null
+++ b/django/web/templates/web/passage/remove_keyword_from_passage.html
@@ -0,0 +1,29 @@
+{% extends "web/base.html" %}
+{% load static i18n %}
+{% load formica %}
+
+{% block content %}
+    <h1 class="mt-6 u-center headline-4 font-alt">
+        {{ passage }}
+    </h1>
+
+    <div class="content">
+        <form
+            action="."
+            method="post">
+            {% form "web/formica/base_form.html" remove_keyword_from_passage_form %}
+                {% fields %}
+            {% endform %}
+            <div class="form-section u-flex u-justify-space-between">
+                <a href="{{ passage.get_absolute_url }}" class="btn u-inline-block">
+                    {% translate "Cancel" %}
+                </a>
+                <button class="btn-info u-inline-block" type="submit">
+                    {% translate "Submit & save" %}
+                </button>
+            </div>
+        </form>
+    </div>
+
+{% endblock %}
+
diff --git a/django/web/templates/web/scholium.html b/django/web/templates/web/scholium.html
index aca069d688e19f831fe19c1ae22a2b2632897500..814d6cb3ddc4e3bf530087c6b853139ef3cd0cd9 100644
--- a/django/web/templates/web/scholium.html
+++ b/django/web/templates/web/scholium.html
@@ -1,26 +1,27 @@
-<html>
+{% extends "web/base.html" %}
+{% load static i18n %}
 
-<body>
+{% block content %}
+    <h1 class="mt-6 u-center headline-4 font-alt">
+        {% translate "Scholium" %} {{ book.number }}.{{ passage.fragment }}{{ passage.sub_fragment }}.{{ scholium.number }}
+    </h1>
+    <h2 class="mt-2 mb-1 u-center headline-6 font-alt">
+        <a href="{% url 'web:passage-detail' book.number passage.fragment passage.sub_fragment %}">
+            {{ passage }}
+        </a>
+    </h2>
 
-    <h1>Scholium
-        {{scholium.passage.book.number}}.{{scholium.passage.fragment}}{{scholium.passage.sub_fragment}}.{{scholium.number}}
-        id: {{scholium.pk}}</h1>
-    {% for text in scholium.texts.all %}
-    <li>{{text.text}} {{text.language}} : {{text.status}} </li>
+    {% include "web/passage/_metadata.html" with obj=scholium only %}
+    {% include "web/passage/_manuscripts.html" with manuscripts=scholium.manuscripts.all only %}
+    {% include "web/passage/_texts.html" with texts=scholium.texts.all  add_text_to_passage_form=add_text_to_passage_form passage_pk=passage.pk csrf_token=csrf_token only %}
 
-    {%endfor%}
-    <li> Passage's creator: {{scholium.creator}}</li>
-    <li> Validation status: {{scholium.validation}}</li>
-    <li> Keywords: {%for keyword in passage.keywords.all%}
-        {%for description in keyword.descriptions.all%}{{description.description}} {%endfor%}{%endfor%}</li>
-    <li> Manuscript images:
-        {% for manuscript in scholium.manuscripts.all %}
-        <img src="{{manuscript.url}}">
-        {% endfor %}
-        {% for description in manuscript.descriptions.all%}
-        {{description.description}}
-        {%endfor%}
-    </li>
-</body>
-
-</html>
\ No newline at end of file
+    {# TODO: pass pertinent forms and links to these reusable templates. #}
+    {% comment %}
+    {% include "web/passage/_cities.html" with cities=cities add_city_to_passage_form=add_city_to_passage_form passage_pk=passage.pk preferred_languages=preferred_languages csrf_token=csrf_token only %}
+    {% include "web/passage/_keywords.html" with keywords=keywords keywords_categories=keywords_categories add_keyword_to_passage_form=add_keyword_to_passage_form passage_pk=passage.pk preferred_languages=preferred_languages csrf_token=csrf_token only %}
+    {% include "web/passage/_comments.html" with comments=comments comment_create_form=comment_create_form passage_pk=passage.pk csrf_token=csrf_token only %}
+    {% include "web/passage/_alignments.html" with texts=texts only %}
+    {% include "web/passage/_internal_references.html" with internal_references=passage.internal_references.all only %}
+    {% include "web/passage/_external_references.html" with external_references=passage.external_references.all only %}
+    {% endcomment %}
+{% endblock %}
diff --git a/django/web/tests/test_add_comment_passage.py b/django/web/tests/test_add_comment_passage.py
index 73eda444ccce697189b6a174af4f5c6684c238f8..a1cb041225be3e0dd9eddcb5f223a443655af5f8 100644
--- a/django/web/tests/test_add_comment_passage.py
+++ b/django/web/tests/test_add_comment_passage.py
@@ -1,28 +1,80 @@
 from django.test import TestCase
 from django.urls import reverse
-from meleager.models import Book, Comment, Description, Passage, Work
-from web.forms.comment import CommentForm
-from web.forms.description import DescriptionForm
 
+from meleager.models import Book, Comment, Description, Passage
 
-class AddCommentFormPassage(TestCase):
-    fixtures = ['test_data.json']
 
-    def setUp(self):
+class CommentsForms(TestCase):
+    fixtures = ["test_data.json"]
+
+    @classmethod
+    def setUpTestData(cls):
         book = Book.objects.first()
-        self.passage = Passage.objects.get(book=book, fragment=42)
+        cls.passage = Passage.objects.get(book=book, fragment=12)
+
+    def test_create_comment(self):
+        self.assertEquals(self.passage.comments.count(), 0)
 
-    def test_form(self):
-        response = self.client.post(
-            reverse("web:comment-add", args=(self.passage.pk,)),
+        self.client.post(
+            reverse("web:comment-create", args=(self.passage.pk,)),
             data={
-                "comment_title": "TEST COMMENT",
-                "description": "TEST DESCRIPTION",
+                "comment_title": "comment_title",
+                "description": "description",
                 "language": "fra",
             },
         )
 
-        self.assertEquals(
-            self.passage.comments.first().descriptions.first().description,
-            "TEST DESCRIPTION",
+        first_description = self.passage.comments.first().descriptions.first()
+        self.assertEquals(first_description.description, "description")
+        self.assertEquals(first_description.language.code, "fra")
+
+    def test_update_comment(self):
+        comment = Comment.objects.create(
+            comment_title="comment_title",
+        )
+        self.passage.comments.add(comment)
+
+        description = Description.objects.create(
+            description="old_content", language_id="eng"
         )
+        comment.descriptions.add(description)
+        self.assertEquals(comment.descriptions.first().description, "old_content")
+        self.assertEquals(comment.descriptions.first().language.code, "eng")
+
+        self.client.post(
+            reverse(
+                "web:comment-update",
+                kwargs={"comment_pk": comment.pk, "passage_pk": self.passage.pk},
+            ),
+            data={
+                "description": "new_content",
+                "language": "spa",
+            },
+        )
+
+        self.assertEquals(comment.descriptions.first().description, "new_content")
+        self.assertEquals(comment.descriptions.first().language.code, "spa")
+
+    def test_delete_comment(self):
+        comment = Comment.objects.create(
+            comment_title="comment_title",
+        )
+        self.passage.comments.add(comment)
+
+        description = Description.objects.create(
+            description="description", language_id="eng"
+        )
+        comment.descriptions.add(description)
+        self.assertEquals(self.passage.comments.count(), 1)
+
+        self.client.post(
+            reverse(
+                "web:comment-delete",
+                kwargs={"comment_pk": comment.pk, "passage_pk": self.passage.pk},
+            ),
+            data={
+                "pk": description.pk,
+            },
+        )
+
+        self.assertEquals(self.passage.comments.count(), 0)
diff --git a/django/web/tests/test_add_description_passage.py b/django/web/tests/test_add_description_passage.py
new file mode 100644
index 0000000000000000000000000000000000000000..7c0c54402ac681059e32c3d7e1dde42003671a51
--- /dev/null
+++ b/django/web/tests/test_add_description_passage.py
@@ -0,0 +1,78 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from meleager.models import Book, Description, Passage
+
+
+class DescriptionsForms(TestCase):
+    fixtures = ["test_data.json"]
+
+    @classmethod
+    def setUpTestData(cls):
+        book = Book.objects.first()
+        cls.passage = Passage.objects.get(book=book, fragment=12)
+        cls.passage.descriptions.clear()
+
+    def test_create_description(self):
+        self.assertEquals(self.passage.descriptions.count(), 0)
+
+        self.client.post(
+            reverse("web:description-create", args=(self.passage.pk,)),
+            data={
+                "description": "description",
+                "language": "fra",
+            },
+        )
+
+        first_description = self.passage.descriptions.first()
+        self.assertEquals(first_description.description, "description")
+        self.assertEquals(first_description.language.code, "fra")
+
+    def test_update_description(self):
+        description = Description.objects.create(
+            description="old_content", language_id="eng"
+        )
+        self.passage.descriptions.add(description)
+        old_first_description = self.passage.descriptions.first()
+        self.assertEquals(old_first_description.description, "old_content")
+        self.assertEquals(old_first_description.language.code, "eng")
+
+        self.client.post(
+            reverse(
+                "web:description-update",
+                kwargs={
+                    "description_pk": description.pk,
+                    "passage_pk": self.passage.pk,
+                },
+            ),
+            data={
+                "description": "new_content",
+                "language": "spa",
+            },
+        )
+
+        new_first_description = self.passage.descriptions.first()
+        self.assertEquals(new_first_description.description, "new_content")
+        self.assertEquals(new_first_description.language.code, "spa")
+
+    def test_delete_description(self):
+        description = Description.objects.create(
+            description="description", language_id="eng"
+        )
+        self.passage.descriptions.add(description)
+        self.assertEquals(self.passage.descriptions.count(), 1)
+
+        self.client.post(
+            reverse(
+                "web:description-delete",
+                kwargs={
+                    "description_pk": description.pk,
+                    "passage_pk": self.passage.pk,
+                },
+            ),
+            data={
+                "pk": description.pk,
+            },
+        )
+
+        self.assertEquals(self.passage.descriptions.count(), 0)
diff --git a/django/web/tests/test_passage.py b/django/web/tests/test_passage.py
index b2b6bdc0aedf3f9b7405c3b49f160c94e085a3df..858c96a074ee9af5d8c40d45fac1216a2769fe6a 100644
--- a/django/web/tests/test_passage.py
+++ b/django/web/tests/test_passage.py
@@ -1,7 +1,4 @@
 from django.test import TestCase
-from meleager.models import Book, Work
-from web.models import APPassage, APScholium
-
 
 class TestURNPassage(TestCase):
     fixtures = ["test_data.json"]
@@ -9,13 +6,13 @@ class TestURNPassage(TestCase):
     def test_homepage_ok(self):
         resp = self.client.get("/")
         self.assertEqual(resp.status_code, 200)
-        self.assertContains(resp, "12.42")
-        self.assertContains(resp, "12.12abc")
+        self.assertContains(resp, "42.12")
+        self.assertContains(resp, "42.69abc")
 
     def test_passage1_ok(self):
-        resp = self.client.get("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:12.42")
+        resp = self.client.get("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:42.12/")
         self.assertEqual(resp.status_code, 200)
 
     def test_passage2_ok(self):
-        resp = self.client.get("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:12.12abc")
+        resp = self.client.get("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:42.69abc/")
         self.assertEqual(resp.status_code, 200)
diff --git a/django/web/urls.py b/django/web/urls.py
deleted file mode 100644
index 90d3327ac790da09c0528a71cc446b5f3279fab2..0000000000000000000000000000000000000000
--- a/django/web/urls.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from django.urls import path, re_path
-
-from .views import index_view
-from .views.authors import AuthorCreate, AuthorDetail, AuthorList, AuthorUpdate
-from .views.cities import CityCreate, CityDetail, CityList, CityUpdate
-from .views.comments import add_comment_from_passage
-from .views.passage import PassageDetail, PassageUpdate, AddAuthorToPassage, AddKeywordToPassage, passage_detail_pk
-from .views.scholium import scholium_view
-from .views.texts import AddTextFromPassage
-
-app_name = "web"
-
-urlpatterns = [
-    path("", index_view, name="index"),
-    re_path(
-        "passages/urn:cts:greekLit:tlg7000.tlg001.ag:(?P<book>\d+).(?P<fragment>\d+)(?P<sub_fragment>[^/]*)/",
-        PassageDetail.as_view(),
-        name="passage-detail",
-    ),
-    path("passages/<int:passage_pk>/", passage_detail_pk, name="passage-detail-pk"),
-    path(
-        "passages/urn:cts:greekLit:tlg5011.tlg001.sag:<int:book>.<int:fragment>.<int:number>/",
-        scholium_view,
-        name="scholium",
-    ),
-    path("passages/<int:pk>/edit/", PassageUpdate.as_view(), name="passage-update"),
-    path("passages/<int:passage_pk>/authors/edit/", AddAuthorToPassage.as_view(), name="passage-add-author"),
-    path("passages/<int:passage_pk>/keywords/edit/", AddKeywordToPassage.as_view(), name="passage-add-keyword"),
-    path(
-        "comments/add/passage/<int:passage_pk>/",
-        add_comment_from_passage,
-        name="comment-add",
-    ),
-    path(
-        "texts/add/passage/<int:passage_pk>/",
-        AddTextFromPassage.as_view(),
-        name="text-add",
-    ),
-    path("authors/", AuthorList.as_view(), name="author-list"),
-    path("authors/new/", AuthorCreate.as_view(), name="author-update"),
-    path("authors/<int:pk>/", AuthorDetail.as_view(), name="author-detail"),
-    path("authors/<int:pk>/edit/", AuthorUpdate.as_view(), name="author-update"),
-    path("cities/", CityList.as_view(), name="city-list"),
-    path("cities/new/", CityCreate.as_view(), name="city-update"),
-    path("cities/<int:pk>/", CityDetail.as_view(), name="city-detail"),
-    path("cities/<int:pk>/edit/", CityUpdate.as_view(), name="city-update"),
-]
diff --git a/django/web/urls/__init__.py b/django/web/urls/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..116632385eaa5c7da3a379b189b832ad24853dd4
--- /dev/null
+++ b/django/web/urls/__init__.py
@@ -0,0 +1,24 @@
+from django.urls import path, include
+
+from ..views import index_view
+from .cities import urlpatterns as cities_urls
+from .passages import urlpatterns as passages_urls
+from .descriptions import urlpatterns as descriptions_urls
+from .authors import urlpatterns as authors_urls
+from .comments import urlpatterns as comments_urls
+from .keywords import urlpatterns as keywords_urls
+from .texts import urlpatterns as texts_urls
+
+
+app_name = "web"
+
+urlpatterns = [
+    path("", index_view, name="index"),
+    path("cities/", include(cities_urls)),
+    path("passages/", include(passages_urls)),
+    path("descriptions/", include(descriptions_urls)),
+    path("authors/", include(authors_urls)),
+    path("comments/", include(comments_urls)),
+    path("keywords/", include(keywords_urls)),
+    path("texts/", include(texts_urls)),
+]
diff --git a/django/web/urls/authors.py b/django/web/urls/authors.py
new file mode 100644
index 0000000000000000000000000000000000000000..247a2c210e38d70259004187ee3d943c6a012cf3
--- /dev/null
+++ b/django/web/urls/authors.py
@@ -0,0 +1,27 @@
+from django.urls import path
+
+from ..views.authors import (
+    AuthorCreate,
+    AuthorDetail,
+    AuthorList,
+    AuthorUpdate,
+    add_author_to_passage,
+    remove_author_from_passage,
+)
+
+urlpatterns = [
+    path(
+        "add/passages/<int:passage_pk>/",
+        add_author_to_passage,
+        name="passage-add-author",
+    ),
+    path(
+        "remove/passages/<int:passage_pk>/",
+        remove_author_from_passage,
+        name="passage-remove-author",
+    ),
+    path("", AuthorList.as_view(), name="author-list"),
+    path("new/", AuthorCreate.as_view(), name="author-update"),
+    path("<int:pk>/", AuthorDetail.as_view(), name="author-detail"),
+    path("<int:pk>/edit/", AuthorUpdate.as_view(), name="author-update"),
+]
diff --git a/django/web/urls/cities.py b/django/web/urls/cities.py
new file mode 100644
index 0000000000000000000000000000000000000000..4fef12f00084c5d1e23190a112df47ac4a3136ee
--- /dev/null
+++ b/django/web/urls/cities.py
@@ -0,0 +1,27 @@
+from django.urls import path
+
+from ..views.cities import (
+    CityCreate,
+    CityDetail,
+    CityList,
+    CityUpdate,
+    add_city_to_passage,
+    remove_city_from_passage,
+)
+
+urlpatterns = [
+    path(
+        "add/passage/<int:passage_pk>/",
+        add_city_to_passage,
+        name="passage-add-city",
+    ),
+    path(
+        "remove/passage/<int:passage_pk>/",
+        remove_city_from_passage,
+        name="passage-remove-city",
+    ),
+    path("", CityList.as_view(), name="city-list"),
+    path("new/", CityCreate.as_view(), name="city-update"),
+    path("<int:pk>/", CityDetail.as_view(), name="city-detail"),
+    path("<int:pk>/edit/", CityUpdate.as_view(), name="city-update"),
+]
diff --git a/django/web/urls/comments.py b/django/web/urls/comments.py
new file mode 100644
index 0000000000000000000000000000000000000000..6f8c932fb31be601c171e5de1652c66de27a1460
--- /dev/null
+++ b/django/web/urls/comments.py
@@ -0,0 +1,26 @@
+from django.urls import path
+from ..views.comments import (
+    comment_create,
+    comment_delete,
+    comment_update,
+    CommentDetail,
+)
+
+urlpatterns = [
+    path(
+        "<int:passage_pk>/create/",
+        comment_create,
+        name="comment-create",
+    ),
+    path(
+        "<int:passage_pk>/<int:comment_pk>/update/",
+        comment_update,
+        name="comment-update",
+    ),
+    path(
+        "<int:passage_pk>/<int:comment_pk>/delete/",
+        comment_delete,
+        name="comment-delete",
+    ),
+    path("<int:pk>/", CommentDetail.as_view(), name="comment-detail"),
+]
diff --git a/django/web/urls/descriptions.py b/django/web/urls/descriptions.py
new file mode 100644
index 0000000000000000000000000000000000000000..7e5a2898c0f2c2fe34408978bd63d776230dc59c
--- /dev/null
+++ b/django/web/urls/descriptions.py
@@ -0,0 +1,25 @@
+from django.urls import path
+
+from ..views.descriptions import (
+    description_create,
+    description_delete,
+    description_update,
+)
+
+urlpatterns = [
+    path(
+        "<int:passage_pk>/create/",
+        description_create,
+        name="description-create",
+    ),
+    path(
+        "<int:passage_pk>/<int:description_pk>/update/",
+        description_update,
+        name="description-update",
+    ),
+    path(
+        "<int:passage_pk>/<int:description_pk>/delete/",
+        description_delete,
+        name="description-delete",
+    ),
+]
diff --git a/django/web/urls/keywords.py b/django/web/urls/keywords.py
new file mode 100644
index 0000000000000000000000000000000000000000..03de00b356693f189e934d795d8682d701d1fbe9
--- /dev/null
+++ b/django/web/urls/keywords.py
@@ -0,0 +1,16 @@
+from django.urls import path
+
+from ..views.keywords import add_keyword_to_passage, remove_keyword_from_passage
+
+urlpatterns = [
+    path(
+        "add/<int:passage_pk>/",
+        add_keyword_to_passage,
+        name="passage-add-keyword",
+    ),
+    path(
+        "remove/<int:passage_pk>/",
+        remove_keyword_from_passage,
+        name="passage-remove-keyword",
+    ),
+]
diff --git a/django/web/urls/passages.py b/django/web/urls/passages.py
new file mode 100644
index 0000000000000000000000000000000000000000..a3d12e88c5b93e41d705090565d2093a14aeaa8d
--- /dev/null
+++ b/django/web/urls/passages.py
@@ -0,0 +1,22 @@
+from django.urls import path, re_path
+from ..views.passage import (
+    PassageDetail,
+    PassageUpdate,
+    passage_detail_pk,
+)
+from ..views.scholium import scholium_view
+
+urlpatterns = [
+    re_path(
+        "urn:cts:greekLit:tlg7000.tlg001.ag:(?P<book>\d+).(?P<fragment>\d+)(?P<sub_fragment>[^/]*)/",
+        PassageDetail.as_view(),
+        name="passage-detail",
+    ),
+    path("<int:passage_pk>/", passage_detail_pk, name="passage-detail-pk"),
+    path(
+        "urn:cts:greekLit:tlg5011.tlg001.sag:<int:book>.<int:fragment>.<int:number>/",
+        scholium_view,
+        name="scholium",
+    ),
+    path("<int:pk>/edit/", PassageUpdate.as_view(), name="passage-update"),
+]
diff --git a/django/web/urls/texts.py b/django/web/urls/texts.py
new file mode 100644
index 0000000000000000000000000000000000000000..7a437dc23c7649996dfa4bb502804b0f5d6dc1fd
--- /dev/null
+++ b/django/web/urls/texts.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from ..views.texts import AddTextToPassage
+
+urlpatterns = [
+    path(
+        "add/passage/<int:passage_pk>/",
+        AddTextToPassage.as_view(),
+        name="text-add",
+    ),
+]
diff --git a/django/web/views/authors.py b/django/web/views/authors.py
index da7f10c450ed225b33c9961c09ea02b224b49a3e..b6cacb1c0bf59b4b9ffd8a7d00939b0587cc7b16 100644
--- a/django/web/views/authors.py
+++ b/django/web/views/authors.py
@@ -1,8 +1,9 @@
-from django import forms
-from django.shortcuts import get_object_or_404, render
+from django.shortcuts import get_object_or_404, render, redirect
 from django.views import generic
-from meleager.models import Author, Name
+
+from meleager.models import Author, Passage
 from web.forms.author import AuthorForm
+from web.forms.passage import AddAuthorToPassageForm, RemoveFromPassageForm
 
 
 class AuthorCreate(generic.CreateView):
@@ -28,3 +29,63 @@ class AuthorList(generic.ListView):
     model = Author
     template_name = "web/author/list.html"
     context_object_name = "authors"
+
+
+def add_author_to_passage(request, passage_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/add_author_to_passage.html",
+            {
+                "passage": passage,
+                "add_author_to_passage_form": AddAuthorToPassageForm(),
+            },
+        )
+
+    add_author_to_passage_form = AddAuthorToPassageForm(request.POST)
+
+    if add_author_to_passage_form.is_valid():
+        author_pk = add_author_to_passage_form.cleaned_data.get("author")
+        author = Author.objects.get(pk=author_pk)
+        passage.authors.add(author)
+        return redirect(f"{passage.get_absolute_url()}#authors")
+    else:
+        return render(
+            request,
+            "web/passage/add_author_to_passage.html",
+            {
+                "passage": passage,
+                "add_author_to_passage_form": add_author_to_passage_form,
+            },
+        )
+
+
+def remove_author_from_passage(request, passage_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/remove_author_from_passage_form.html",
+            {
+                "passage": passage,
+                "remove_author_from_passage_form": RemoveFromPassageForm(),
+            },
+        )
+
+    remove_author_from_passage_form = RemoveFromPassageForm(request.POST)
+
+    if remove_author_from_passage_form.is_valid():
+        author_pk = remove_author_from_passage_form.cleaned_data.get("pk")
+        author = Author.objects.get(pk=author_pk)
+        passage.authors.remove(author)
+        return redirect(f"{passage.get_absolute_url()}#authors")
+    else:
+        return render(
+            request,
+            "web/passage/remove_author_from_passage_form.html",
+            {
+                "passage": passage,
+                "remove_author_from_passage_form": remove_author_from_passage_form,
+            },
+        )
diff --git a/django/web/views/cities.py b/django/web/views/cities.py
index 4069672229aeeeb4702af57bc021ac206f2d4d52..305f103ce9aef7ef972ab65bfbdad6178c2f0019 100644
--- a/django/web/views/cities.py
+++ b/django/web/views/cities.py
@@ -1,6 +1,69 @@
 from django.views import generic
-from meleager.models import City
+from django.shortcuts import get_object_or_404, redirect, render
+
+from meleager.models import City, Passage
 from web.forms.city import CityForm
+from web.forms.passage import AddCityToPassageForm, RemoveFromPassageForm
+
+
+def add_city_to_passage(request, passage_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/add_city_to_passage.html",
+            {
+                "passage": passage,
+                "add_city_to_passage_form": AddCityToPassageForm(),
+            },
+        )
+
+    add_city_to_passage_form = AddCityToPassageForm(request.POST)
+
+    if add_city_to_passage_form.is_valid():
+        city_pk = add_city_to_passage_form.cleaned_data.get("city")
+        city = City.objects.get(pk=city_pk)
+        passage.cities.add(city)
+        return redirect(f"{passage.get_absolute_url()}#cities")
+    else:
+        return render(
+            request,
+            "web/passage/add_city_to_passage.html",
+            {
+                "passage": passage,
+                "add_city_to_passage_form": add_city_to_passage_form,
+            },
+        )
+
+
+def remove_city_from_passage(request, passage_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/remove_city_from_passage.html",
+            {
+                "passage": passage,
+                "remove_city_from_passage_form": RemoveFromPassageForm(),
+            },
+        )
+
+    remove_city_from_passage_form = RemoveFromPassageForm(request.POST)
+
+    if remove_city_from_passage_form.is_valid():
+        city_pk = remove_city_from_passage_form.cleaned_data.get("pk")
+        city = City.objects.get(pk=city_pk)
+        passage.cities.remove(city)
+        return redirect(f"{passage.get_absolute_url()}#cities")
+    else:
+        return render(
+            request,
+            "web/passage/remove_city_from_passage.html",
+            {
+                "passage": passage,
+                "remove_city_from_passage_form": remove_city_from_passage_form,
+            },
+        )
 
 
 class CityList(generic.ListView):
diff --git a/django/web/views/comments.py b/django/web/views/comments.py
index e81c518870ad3faf5a9113536d47487c67372212..9e8af55ac4223f0b1befe34247a8e90d9ebfa9ae 100644
--- a/django/web/views/comments.py
+++ b/django/web/views/comments.py
@@ -1,32 +1,114 @@
 from django.shortcuts import get_object_or_404, redirect, render
+from django.views import generic
+
+from meleager.models import Comment, Description, Passage
 from web.forms.comment import CommentForm
-from web.forms.comment_description import CommentDescriptionForm
+from web.forms.comment_description import (
+    CommentCreateForm,
+    CommentDeleteForm,
+    CommentUpdateForm,
+)
 from web.forms.description import DescriptionForm
-from web.models import APPassage
 
 
-def add_comment_from_passage(request, passage_pk):
-    passage = get_object_or_404(APPassage, pk=passage_pk)
+def comment_create(request, passage_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
     if request.method == "GET":
         return render(
             request,
-            "web/add_comment_from_passage.html",
-            {"passage": passage, "comment_description_form": CommentDescriptionForm()},
+            "web/passage/comment_create.html",
+            {
+                "passage": passage,
+                "comment_create_form": CommentCreateForm(),
+            },
         )
     description_form = DescriptionForm(request.POST)
     comment_form = CommentForm(request.POST)
-    comment_description_form = CommentDescriptionForm(request.POST)
+    comment_create_form = CommentCreateForm(request.POST)
 
     if description_form.is_valid() and comment_form.is_valid():
         description = description_form.save()
         comment = comment_form.save()
         comment.descriptions.add(description)
-        passage = APPassage.objects.get(pk=passage_pk)
         passage.comments.add(comment)
         return redirect(f"{passage.get_absolute_url()}#comment-{comment.pk}")
     else:
         return render(
             request,
-            "web/add_comment_from_passage.html",
-            {"passage": passage, "comment_description_form": comment_description_form},
+            "web/comment_create.html",
+            {
+                "passage": passage,
+                "comment_create_form": comment_create_form,
+            },
+        )
+
+
+def comment_delete(request, passage_pk, comment_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/comment_delete.html",
+            {
+                "passage": passage,
+                "comment_delete": CommentDeleteForm(),
+            },
+        )
+
+    comment = get_object_or_404(Comment, pk=comment_pk)
+    comment_delete_form = CommentDeleteForm(request.POST)
+
+    if comment_delete_form.is_valid():
+        description_pk = comment_delete_form.cleaned_data.get("pk")
+        description = Description.objects.get(pk=description_pk)
+        description.delete()
+        comment.delete()
+        return redirect(f"{passage.get_absolute_url()}#comments")
+    else:
+        return render(
+            request,
+            "web/passage/comment_delete.html",
+            {
+                "passage": passage,
+                "comment_delete_form": comment_delete_form,
+            },
         )
+
+
+def comment_update(request, passage_pk, comment_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/comment_update.html",
+            {
+                "passage": passage,
+                "comment_update_form": CommentUpdateForm(),
+            },
+        )
+
+    comment_update_form = CommentUpdateForm(request.POST)
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    description_form = DescriptionForm(request.POST)
+    if description_form.is_valid():
+        comment_obj = get_object_or_404(Comment, pk=comment_pk)
+        # TODO: handle multiple descriptions?
+        description_obj = comment_obj.descriptions.first()
+        description_obj.description = description_form.cleaned_data["description"]
+        description_obj.language = description_form.cleaned_data["language"]
+        description_obj.save()
+        return redirect(f"{passage.get_absolute_url()}#comment-{comment_pk}")
+    else:
+        return render(
+            request,
+            "web/comment_update.html",
+            {
+                "passage": passage,
+                "comment_update_form": comment_update_form,
+            },
+        )
+
+
+class CommentDetail(generic.DetailView):
+    model = Comment
+    template_name = "web/comment/detail.html"
diff --git a/django/web/views/descriptions.py b/django/web/views/descriptions.py
new file mode 100644
index 0000000000000000000000000000000000000000..12698f72fc5046f324a45fc51899abbecf4b3a77
--- /dev/null
+++ b/django/web/views/descriptions.py
@@ -0,0 +1,100 @@
+from django.shortcuts import get_object_or_404, redirect, render
+
+from meleager.models import Description, Passage
+from web.forms.passage_description import (
+    DescriptionCreateForm,
+    DescriptionDeleteForm,
+    DescriptionUpdateForm,
+)
+from web.forms.description import DescriptionForm
+
+
+def description_create(request, passage_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/description_create.html",
+            {
+                "passage": passage,
+                "description_create_form": DescriptionCreateForm(),
+            },
+        )
+
+    description_create_form = DescriptionForm(request.POST)
+
+    if description_create_form.is_valid():
+        description = description_create_form.save()
+        passage.descriptions.add(description)
+        return redirect(f"{passage.get_absolute_url()}#description-{description.pk}")
+    else:
+        return render(
+            request,
+            "web/description_create.html",
+            {
+                "passage": passage,
+                "description_create_form": description_create_form,
+            },
+        )
+
+
+def description_delete(request, passage_pk, description_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/description_delete.html",
+            {
+                "passage": passage,
+                "description_delete": DescriptionDeleteForm(),
+            },
+        )
+
+    description_delete_form = DescriptionDeleteForm(request.POST)
+
+    if description_delete_form.is_valid():
+        description_pk = description_delete_form.cleaned_data.get("pk")
+        description = Description.objects.get(pk=description_pk)
+        description.delete()
+        return redirect(f"{passage.get_absolute_url()}#descriptions")
+    else:
+        return render(
+            request,
+            "web/passage/description_delete.html",
+            {
+                "passage": passage,
+                "description_delete_form": description_delete_form,
+            },
+        )
+
+
+def description_update(request, passage_pk, description_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/description_update.html",
+            {
+                "passage": passage,
+                "description_update_form": DescriptionUpdateForm(),
+            },
+        )
+
+    description_update_form = DescriptionUpdateForm(request.POST)
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    description_form = DescriptionForm(request.POST)
+    if description_form.is_valid():
+        description = Description.objects.get(pk=description_pk)
+        description.description = description_form.cleaned_data["description"]
+        description.language = description_form.cleaned_data["language"]
+        description.save()
+        return redirect(f"{passage.get_absolute_url()}#description-{description.pk}")
+    else:
+        return render(
+            request,
+            "web/description_update.html",
+            {
+                "passage": passage,
+                "description_update_form": description_update_form,
+            },
+        )
diff --git a/django/web/views/keywords.py b/django/web/views/keywords.py
index 1c028a93674312697238cc556d06a865c0d59a38..b4b1fc02ce35bd9248559e2d04763f19946837c5 100644
--- a/django/web/views/keywords.py
+++ b/django/web/views/keywords.py
@@ -1,7 +1,69 @@
-from django.shortcuts import render
-from meleager.models import Keyword
+from django.shortcuts import get_object_or_404, render, redirect
+
+from meleager.models import Keyword, Passage
+from web.forms.passage import AddKeywordToPassageForm, RemoveFromPassageForm
 
 
 def keywords(request):
     keywords = Keyword.objects.all()
     return render(request, "web/keywords.html", {"keywords": keywords})
+
+
+def add_keyword_to_passage(request, passage_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/add_keyword_to_passage.html",
+            {
+                "passage": passage,
+                "add_keyword_to_passage_form": AddKeywordToPassageForm(),
+            },
+        )
+
+    add_keyword_to_passage_form = AddKeywordToPassageForm(request.POST)
+
+    if add_keyword_to_passage_form.is_valid():
+        keyword_pk = add_keyword_to_passage_form.cleaned_data.get("keyword")
+        keyword = Keyword.objects.get(pk=keyword_pk)
+        passage.keywords.add(keyword)
+        return redirect(f"{passage.get_absolute_url()}#keywords")
+    else:
+        return render(
+            request,
+            "web/passage/add_keyword_to_passage.html",
+            {
+                "passage": passage,
+                "add_keyword_to_passage_form": add_keyword_to_passage_form,
+            },
+        )
+
+
+def remove_keyword_from_passage(request, passage_pk):
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    if request.method == "GET":
+        return render(
+            request,
+            "web/passage/remove_keyword_from_passage.html",
+            {
+                "passage": passage,
+                "remove_keyword_from_passage_form": RemoveFromPassageForm(),
+            },
+        )
+
+    remove_keyword_from_passage_form = RemoveFromPassageForm(request.POST)
+
+    if remove_keyword_from_passage_form.is_valid():
+        keyword_pk = remove_keyword_from_passage_form.cleaned_data.get("pk")
+        keyword = Keyword.objects.get(pk=keyword_pk)
+        passage.keywords.remove(keyword)
+        return redirect(f"{passage.get_absolute_url()}#keywords")
+    else:
+        return render(
+            request,
+            "web/passage/remove_keyword_from_passage.html",
+            {
+                "passage": passage,
+                "remove_keyword_from_passage_form": remove_keyword_from_passage_form,
+            },
+        )
diff --git a/django/web/views/passage.py b/django/web/views/passage.py
index 5aef94a8f561f227fd5fb7871ed2f509269cf849..1edcd5db4fd62d28bf91be66ac1a74bbc5d39b18 100644
--- a/django/web/views/passage.py
+++ b/django/web/views/passage.py
@@ -1,20 +1,35 @@
-from django.shortcuts import get_object_or_404, render, redirect, reverse
+from django.shortcuts import get_object_or_404, redirect
 from django.views import generic
-from web.forms.comment_description import CommentDescriptionForm
-from web.forms.passage import PassageForm, AddAuthorToPassageForm, AddKeywordToPassageForm
-from web.forms.text import TextForm
-from web.models import APPassage
-from meleager.models import Author, Keyword
+
+from meleager.models import Language, Passage
+from web.forms.comment_description import (
+    CommentCreateForm,
+    CommentUpdateForm,
+    CommentDeleteForm,
+)
+from web.forms.passage import (
+    PassageForm,
+    AddAuthorToPassageForm,
+    RemoveFromPassageForm,
+    AddCityToPassageForm,
+    AddKeywordToPassageForm,
+)
+from web.forms.passage_description import (
+    DescriptionCreateForm,
+    DescriptionUpdateForm,
+    DescriptionDeleteForm,
+)
+from web.forms.text import AddTextToPassageForm
 
 
 class PassageDetail(generic.DetailView):
-    model = APPassage
-    template_name = "web/passage.html"
+    model = Passage
+    template_name = "web/passage/passage.html"
     context_object_name = "passage"
 
     def get_object(self):
         obj = get_object_or_404(
-            APPassage,
+            Passage,
             book__number=self.kwargs.get("book"),
             fragment=self.kwargs.get("fragment"),
             sub_fragment=self.kwargs.get("sub_fragment", ""),
@@ -24,80 +39,89 @@ class PassageDetail(generic.DetailView):
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         passage = self.get_object()
+        context["book"] = passage.book
+        descriptions = passage.descriptions.all()
+        for description in descriptions:
+            description.update_form = DescriptionUpdateForm(
+                initial={
+                    "pk": description.pk,
+                    "description": description.description,
+                    "language": description.language,
+                }
+            )
+            description.delete_form = DescriptionDeleteForm(
+                initial={
+                    "pk": description.pk,
+                }
+            )
+        context["descriptions"] = descriptions
+        context["description_create_form"] = DescriptionCreateForm()
+
         comments = passage.comments.all()
         for comment in comments:
-            # TODO: how to handle multiple descriptions?
-            for description in comment.descriptions.all():
-                comment.form = CommentDescriptionForm(
-                    initial={
-                        "comment_title": comment.comment_title,
-                        "description": description.description,
-                        "language": description.language,
-                    }
-                )
+            # TODO: handle multiple descriptions?
+            description = comment.descriptions.first()
+            comment.update_form = CommentUpdateForm(
+                initial={
+                    "pk": description.pk,
+                    "description": description.description,
+                    "language": description.language,
+                }
+            )
+            comment.delete_form = CommentDeleteForm(
+                initial={
+                    "pk": description.pk,
+                }
+            )
         context["comments"] = comments
-        context["comment_description_form"] = CommentDescriptionForm()
-        context["text_form"] = TextForm()
-        # Exclude texts without a text, otherwise forloop counters
-        # in templates would be inconsistent.
-        # TODISCUSS: maybe an import_ap issue?
-        context["texts"] = passage.texts.exclude(text="")
+        context["comment_create_form"] = CommentCreateForm()
+
+        context["add_text_to_passage_form"] = AddTextToPassageForm()
+
+        def _sort_grc_first(value):
+            return value.language.code != "grc"
+
+        context["texts"] = sorted(passage.texts.all(), key=_sort_grc_first)
+
+        authors = passage.authors.all()
+        for text in context["texts"]:
+            for author in authors:
+                for name in author.names.all():
+                    if name.language.code == text.language.code:
+                        text.author = author
+                        text.author_name = name
+                author.remove_form = RemoveFromPassageForm(initial={"pk": author.pk})
+        context["authors"] = authors
+        context["add_author_to_passage_form"] = AddAuthorToPassageForm()
+
+        cities = passage.cities.all()
+        for city in cities:
+            city.remove_form = RemoveFromPassageForm(initial={"pk": city.pk})
+        context["cities"] = cities
+        context["add_city_to_passage_form"] = AddCityToPassageForm()
+
+        keywords = passage.keywords.all()
+        for keyword in keywords:
+            keyword.remove_form = RemoveFromPassageForm(initial={"pk": keyword.pk})
+        context["keywords"] = keywords
+        context["keywords_categories"] = {keyword.category for keyword in keywords}
+        context["add_keyword_to_passage_form"] = AddKeywordToPassageForm()
+
+        context["preferred_languages"] = Language.objects.preferred()
         return context
 
+
 def passage_detail_pk(request, passage_pk):
-    passage = get_object_or_404(APPassage, pk=passage_pk)
-    return redirect("web:passage-detail", passage.book.number, passage.fragment, passage.sub_fragment)
+    passage = get_object_or_404(Passage, pk=passage_pk)
+    return redirect(
+        "web:passage-detail",
+        passage.book.number,
+        passage.fragment,
+        passage.sub_fragment,
+    )
+
 
 class PassageUpdate(generic.UpdateView):
-    model = APPassage
-    template_name = "web/passage_form.html"
+    model = Passage
+    template_name = "web/passage/passage_form.html"
     form_class = PassageForm
-
-class AddRelationToPassage(generic.View):
-    related_model = None
-    related_model_form = None
-    relation_name = None
-
-    def get(self, request, passage_pk):
-        passage = get_object_or_404(APPassage, pk=passage_pk)
-
-        all_objects = self.related_model.objects.all()
-        objects_flat = all_objects.filter(names__language__preferred=True).values_list('pk', 'names__name', 'names__language__code')
-
-        objects_nested = {
-            obj.pk: {
-                language: name
-                for language, name in obj.names.values_list('language__code', 'name')
-            }
-            for obj in all_objects
-        }
-
-        return render(
-            request,
-            "web/passage/add_relation_to_passage.html",
-            {'passage': passage, 'objects_flat': objects_flat, 'objects_nested': objects_nested, 'form': self.related_model_form()}
-        )
-
-    def post(self, request, passage_pk):
-        form = self.related_model_form(request.POST)
-        passage = get_object_or_404(APPassage, pk=passage_pk)
-
-        if form.is_valid():
-            obj_pk = form.cleaned_data["entity_pk"]
-            getattr(passage, self.relation_name).add(obj_pk)
-            return redirect("web:passage-detail-pk", passage_pk)
-        else:
-            return render(request, "web/passage/add_relation_to_passage.html",
-            {'passage': passage, 'form_url': reverse(self.form_reverse_url, args=[passage.pk]), 'form': form})
-
-class AddAuthorToPassage(AddRelationToPassage):
-    related_model = Author
-    related_model_form = AddAuthorToPassageForm
-    relation_name = "authors"
-    form_reverse_url = "web:passage-add-author"
-
-class AddKeywordToPassage(AddRelationToPassage):
-    related_model = Keyword
-    related_model_form = AddKeywordToPassageForm
-    relation_name = "keywords"
-    form_reverse_url = "web:passage-add-keyword"
\ No newline at end of file
diff --git a/django/web/views/scholium.py b/django/web/views/scholium.py
index 129165703279fad42f4b9f2006bb41114455a1b8..eeeafc360a6c037eabaa329fbddb699e83cb39e8 100644
--- a/django/web/views/scholium.py
+++ b/django/web/views/scholium.py
@@ -1,13 +1,31 @@
 from django.shortcuts import get_object_or_404, render
-from web.models import APScholium
+
+from meleager.models import Language, Scholium
+from web.forms.passage import AddKeywordToPassageForm
 
 
 def scholium_view(request, book, fragment, number):
-    obj = get_object_or_404(
-        APScholium,
+    scholium = get_object_or_404(
+        Scholium,
         passage__book__number=book,
         passage__fragment=fragment,
         number=number,
     )
+    preferred_languages = Language.objects.preferred()
+    keywords = scholium.keywords.all()
+    keywords_categories = {keyword.category for keyword in keywords}
+    add_keyword_to_passage_form = AddKeywordToPassageForm()
 
-    return render(request, "web/scholium.html", {"scholium": obj})
+    return render(
+        request,
+        "web/scholium.html",
+        {
+            "book": scholium.passage.book,
+            "passage": scholium.passage,
+            "scholium": scholium,
+            "preferred_languages": preferred_languages,
+            "keywords": keywords,
+            "keywords_categories": keywords_categories,
+            "add_keyword_to_passage_form": add_keyword_to_passage_form,
+        },
+    )
diff --git a/django/web/views/texts.py b/django/web/views/texts.py
index 813ac97110d4f94c4bbddde9f7a0528e24512a60..c15cd6cbcbc27ba1ec8f5bb4dd0b249c12484136 100644
--- a/django/web/views/texts.py
+++ b/django/web/views/texts.py
@@ -1,29 +1,33 @@
 from django.shortcuts import get_object_or_404, redirect, render
 from django.views import View
-from web.forms.text import TextForm
-from web.models import APPassage
 
+from web.forms.text import AddTextToPassageForm
+from meleager.models import Passage
 
-class AddTextFromPassage(View):
+
+class AddTextToPassage(View):
     def get(self, request, passage_pk):
-        passage = get_object_or_404(APPassage, pk=passage_pk)
+        passage = get_object_or_404(Passage, pk=passage_pk)
 
         return render(
             request,
-            "web/add_text_from_passage.html",
-            {"passage": passage, "text_form": TextForm()},
+            "web/passage/add_text_to_passage.html",
+            {"passage": passage, "add_text_to_passage_form": AddTextToPassageForm()},
         )
 
     def post(self, request, passage_pk):
-        text_form = TextForm(request.POST)
-        passage = get_object_or_404(APPassage, pk=passage_pk)
-        if text_form.is_valid():
-            text = text_form.save()
+        add_text_to_passage_form = AddTextToPassageForm(request.POST)
+        passage = get_object_or_404(Passage, pk=passage_pk)
+        if add_text_to_passage_form.is_valid():
+            text = add_text_to_passage_form.save()
             passage.texts.add(text)
             return redirect(f"{passage.get_absolute_url()}#texts")
         else:
             return render(
                 request,
-                "web/add_text_from_passage.html",
-                {"passage": passage, "text_form": text_form},
+                "web/passage/add_text_to_passage.html",
+                {
+                    "passage": passage,
+                    "add_text_to_passage_form": add_text_to_passage_form,
+                },
             )
diff --git a/integration/requirements.txt b/integration/requirements.txt
index e1eafae51869c93c126b2c28ae45c890cc3e8e2c..5548ed0c76f1fdd388818a46656200a6df6eca6b 100644
--- a/integration/requirements.txt
+++ b/integration/requirements.txt
@@ -1,3 +1,3 @@
-playwright==0.162.1
+playwright==1.8.0a1
 pytest==6.1.2
-pytest-playwright==0.0.8
+pytest-playwright==0.0.11
diff --git a/integration/test_alignments_hover.py b/integration/test_alignments_hover.py
index ce733218a69a5d30f2f9f7b141502ba0f9b75c09..45417a93cfef973ae6e01d660e61c152eabe79bb 100644
--- a/integration/test_alignments_hover.py
+++ b/integration/test_alignments_hover.py
@@ -1,42 +1,42 @@
 def test_hovering_original_highlight_translation(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
-    original = page.waitForSelector(
-        '#alignments-tabs #alignment-content-1-ENG #align-original-1 blockquote [data-id="align-1-[2]"]'
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
+    original = page.wait_for_selector(
+        '#alignments-tabs #alignment-content-1-eng #align-original-1 blockquote [data-id="align-1-[2]"]'
     )
-    translation = page.waitForSelector(
-        '#alignments-tabs #alignment-content-1-ENG #align-translation-1 blockquote [data-id="align-1-[2]"]'
+    translation = page.wait_for_selector(
+        '#alignments-tabs #alignment-content-1-eng #align-translation-1 blockquote [data-id="align-1-[2]"]'
     )
-    assert original.getAttribute("class") is None
-    assert translation.getAttribute("class") is None
+    assert original.get_attribute("class") is None
+    assert translation.get_attribute("class") is None
     original.hover()
-    assert original.getAttribute("class") == "tag tag--warning tag--normal"
-    assert translation.getAttribute("class") == "tag tag--warning tag--normal"
+    assert original.get_attribute("class") == "tag tag--warning tag--normal"
+    assert translation.get_attribute("class") == "tag tag--warning tag--normal"
     # Cannot find how to un-hover, so let’s hover another element…
-    page.waitForSelector(
-        '#alignments-tabs #alignment-content-1-ENG #align-original-1 blockquote [data-id="align-1-[3]"]'
+    page.wait_for_selector(
+        '#alignments-tabs #alignment-content-1-eng #align-original-1 blockquote [data-id="align-1-[3]"]'
     ).hover()
-    assert original.getAttribute("class") == ""
-    assert translation.getAttribute("class") == ""
+    assert original.get_attribute("class") == ""
+    assert translation.get_attribute("class") == ""
 
 
 def test_hovering_original_highlight_translation_from_another_tab(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
-    tab = page.waitForSelector('#alignments-tabs [href="#alignment-content-2-FRA"]')
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
+    tab = page.wait_for_selector('#alignments-tabs [href="#alignment-content-2-fra"]')
     tab.click()
-    original = page.waitForSelector(
-        '#alignments-tabs #alignment-content-2-FRA #align-original-2 blockquote [data-id="align-2-[2]"]'
+    original = page.wait_for_selector(
+        '#alignments-tabs #alignment-content-2-fra #align-original-2 blockquote [data-id="align-2-[2]"]'
     )
-    translation = page.waitForSelector(
-        '#alignments-tabs #alignment-content-2-FRA #align-translation-2 blockquote [data-id="align-2-[2]"]'
+    translation = page.wait_for_selector(
+        '#alignments-tabs #alignment-content-2-fra #align-translation-2 blockquote [data-id="align-2-[2]"]'
     )
-    assert original.getAttribute("class") is None
-    assert translation.getAttribute("class") is None
+    assert original.get_attribute("class") is None
+    assert translation.get_attribute("class") is None
     original.hover()
-    assert original.getAttribute("class") == "tag tag--warning tag--normal"
-    assert translation.getAttribute("class") == "tag tag--warning tag--normal"
+    assert original.get_attribute("class") == "tag tag--warning tag--normal"
+    assert translation.get_attribute("class") == "tag tag--warning tag--normal"
     # Cannot find how to un-hover, so let’s hover another element…
-    page.waitForSelector(
-        '#alignments-tabs #alignment-content-2-FRA #align-original-2 blockquote [data-id="align-2-[3]"]'
+    page.wait_for_selector(
+        '#alignments-tabs #alignment-content-2-fra #align-original-2 blockquote [data-id="align-2-[3]"]'
     ).hover()
-    assert original.getAttribute("class") == ""
-    assert translation.getAttribute("class") == ""
+    assert original.get_attribute("class") == ""
+    assert translation.get_attribute("class") == ""
diff --git a/integration/test_alignments_tabs.py b/integration/test_alignments_tabs.py
index f384d117c1345c6e43e97bf7e14c171c942b19c5..a667f8d365408d00a42acfb91bf887fc4c445412 100644
--- a/integration/test_alignments_tabs.py
+++ b/integration/test_alignments_tabs.py
@@ -1,15 +1,15 @@
 def test_first_tab_is_selected(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
     assert (
-        page.textContent("#alignments-tabs .tab-container .selected").strip() == "ENG"
+        page.text_content("#alignments-tabs .tab-container .selected").strip() == "eng"
     )
 
 
 def test_first_content_is_visible(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
     assert (
-        page.innerText(
-            "#alignments-tabs #alignment-content-1-ENG #align-original-1 blockquote"
+        page.inner_text(
+            "#alignments-tabs #alignment-content-1-eng #align-original-1 blockquote"
         )
         .strip()
         .startswith("νῦν πλέον ἢ τὸ πάροιθε")
@@ -17,15 +17,15 @@ def test_first_content_is_visible(page):
 
 
 def test_click_on_french_tab(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
-    tab = page.waitForSelector('#alignments-tabs [href="#alignment-content-2-FRA"]')
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
+    tab = page.wait_for_selector('#alignments-tabs [href="#alignment-content-2-fra"]')
     tab.click()
     assert (
-        page.textContent("#alignments-tabs .tab-container .selected").strip() == "FRA"
+        page.text_content("#alignments-tabs .tab-container .selected").strip() == "fra"
     )
     assert (
-        page.innerText(
-            "#alignments-tabs #alignment-content-2-FRA #align-translation-2 blockquote"
+        page.inner_text(
+            "#alignments-tabs #alignment-content-2-fra #align-translation-2 blockquote"
         )
         .strip()
         .startswith("Maintenant plus qu ' auparavant , garde , triple chien")
diff --git a/integration/test_authors.py b/integration/test_authors.py
new file mode 100644
index 0000000000000000000000000000000000000000..b992f1b68748f3559a39ac0292fe82ca34e42cc6
--- /dev/null
+++ b/integration/test_authors.py
@@ -0,0 +1,10 @@
+def test_submitting_author_form(page):
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.71/")
+    page.hover("#authors h2")
+    page.click("#authors h2 a >> text='Add'")
+    modal = page.wait_for_selector("#author-new")
+    author_select = modal.wait_for_selector("#id_author")
+    author_select.select_option(label="Adaeus, ᾿Αδαῖος, Addée ou Adaios de Macédoine")
+    submit_button = modal.wait_for_selector("button[type=submit]")
+    # submit_button.click()
+    # TODO: find the correct Playwright assertion in this context.
diff --git a/integration/test_cities.py b/integration/test_cities.py
new file mode 100644
index 0000000000000000000000000000000000000000..0c26fefb15788342a3648345eda6ba2554e7b9f7
--- /dev/null
+++ b/integration/test_cities.py
@@ -0,0 +1,10 @@
+def test_submitting_city_form(page):
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.71/")
+    page.hover("#cities h2")
+    page.click("#cities h2 a >> text='Add'")
+    modal = page.wait_for_selector("#city-new")
+    keyword_select = modal.wait_for_selector("#id_city")
+    keyword_select.select_option(label="Myrina, Myrina, Μυρίνα")
+    submit_button = modal.wait_for_selector("button[type=submit]")
+    # submit_button.click()
+    # TODO: find the correct Playwright assertion in this context.
diff --git a/integration/test_comments.py b/integration/test_comments.py
index 4f11f06803ab0586b9bfbe2e74cd115c20ba82d9..097b70ad8fa3c7743c445e737478f779e47111ac 100644
--- a/integration/test_comments.py
+++ b/integration/test_comments.py
@@ -1,76 +1,79 @@
 def test_open_close_comment_form_modal(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
 
     # The opacity of the modal should be 0 by default.
-    modal = page.waitForSelector("#comment-new")
+    modal = page.wait_for_selector("#comment-create")
     opacity = modal.evaluate("e => window.getComputedStyle(e).opacity")
     assert opacity == "0"
 
-    page.click("a >> text=/.*Add a new comment/")
+    page.hover("#comments h2")
+    page.click("#comments h2 a >> text='Add'")
     # Manually waiting for the .3s CSS transition.
-    page.waitForTimeout(350)
+    page.wait_for_timeout(350)
 
     # Once opened, the opacity is set to 1.
-    modal = page.waitForSelector("#comment-new")
+    modal = page.wait_for_selector("#comment-create")
     opacity = modal.evaluate("e => window.getComputedStyle(e).opacity")
     assert opacity == "1"
     assert (
-        modal.waitForSelector(".modal-title").textContent().strip()
+        modal.wait_for_selector(".modal-title").text_content().strip()
         == "Add a new comment"
     )
 
     # The Cancel link is closing the modal.
-    cancel_link = modal.waitForSelector("text=Cancel")
+    cancel_link = modal.wait_for_selector("text=Cancel")
     cancel_link.click()
     # Manually waiting for the .3s CSS transition.
-    page.waitForTimeout(350)
+    page.wait_for_timeout(350)
 
-    modal = page.waitForSelector("#comment-new")
+    modal = page.wait_for_selector("#comment-create")
     opacity = modal.evaluate("e => window.getComputedStyle(e).opacity")
     assert opacity == "0"
 
 
 def test_open_close_with_escape_key_comment_form_modal(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
 
     # The opacity of the modal should be 0 by default.
-    modal = page.waitForSelector("#comment-new")
+    modal = page.wait_for_selector("#comment-create")
     opacity = modal.evaluate("e => window.getComputedStyle(e).opacity")
     assert opacity == "0"
 
-    page.click("a >> text=/.*Add a new comment/")
+    page.hover("#comments h2")
+    page.click("#comments h2 a >> text='Add'")
     # Manually waiting for the .3s CSS transition.
-    page.waitForTimeout(350)
+    page.wait_for_timeout(350)
 
     # Once opened, the opacity is set to 1.
-    modal = page.waitForSelector("#comment-new")
+    modal = page.wait_for_selector("#comment-create")
     opacity = modal.evaluate("e => window.getComputedStyle(e).opacity")
     assert opacity == "1"
     assert (
-        modal.waitForSelector(".modal-title").textContent().strip()
+        modal.wait_for_selector(".modal-title").text_content().strip()
         == "Add a new comment"
     )
 
     # The escape key is closing the modal.
     page.keyboard.press("Escape")
     # Manually waiting for the .3s CSS transition.
-    page.waitForTimeout(350)
+    page.wait_for_timeout(350)
 
-    modal = page.waitForSelector("#comment-new")
+    modal = page.wait_for_selector("#comment-create")
     opacity = modal.evaluate("e => window.getComputedStyle(e).opacity")
     assert opacity == "0"
 
 
 def test_submitting_comment_form(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
-    page.click("a >> text=/.*Add a new comment/")
-    modal = page.waitForSelector("#comment-new")
-    title_input = modal.waitForSelector("#id_comment_title")
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
+    page.hover("#comments h2")
+    page.click("#comments h2 a >> text='Add'")
+    modal = page.wait_for_selector("#comment-create")
+    title_input = modal.wait_for_selector("#id_comment_title")
     title_input.fill("Title")
-    description_textarea = modal.waitForSelector("#id_description")
+    description_textarea = modal.wait_for_selector("#id_description")
     description_textarea.fill("Description")
-    language_input = modal.waitForSelector("#id_language")
-    language_input.fill("fra")
-    submit_button = modal.waitForSelector("button[type=submit]")
+    language_select = modal.wait_for_selector("#id_language")
+    language_select.select_option("fra")
+    submit_button = modal.wait_for_selector("button[type=submit]")
     # submit_button.click()
     # TODO: find the correct Playwright assertion in this context.
diff --git a/integration/test_default_interface.py b/integration/test_default_interface.py
index 4cc11cef75b22c65ff290fb2e3b4af856d0be451..2635e75117fe964faafdc3c8828166bd425a5c6c 100644
--- a/integration/test_default_interface.py
+++ b/integration/test_default_interface.py
@@ -1,8 +1,13 @@
 def test_site_title(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
-    assert page.textContent("header h1").strip() == "Anthologia Graeca"
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
+    assert page.text_content("header h1").strip() == "Anthologia Graeca"
 
 
-def test_page_title(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
-    assert page.textContent("section h1").strip() == "Passage 7.70"
+def test_passage_title(page):
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
+    assert page.text_content("section h1").strip() == "Passage 7.70"
+
+
+def test_scholium_title(page):
+    page.goto("/passages/urn:cts:greekLit:tlg5011.tlg001.sag:7.70.1/")
+    assert page.text_content("section h1").strip() == "Scholium 7.70.1"
diff --git a/integration/test_descriptions.py b/integration/test_descriptions.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd5bd7a00b9784bcb4e225cae3a01e24da0839de
--- /dev/null
+++ b/integration/test_descriptions.py
@@ -0,0 +1,12 @@
+def test_submitting_description_form(page):
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
+    page.hover("#descriptions h2")
+    page.click("#descriptions h2 a >> text='Add'")
+    modal = page.wait_for_selector("#description-create")
+    description_textarea = modal.wait_for_selector("#id_description")
+    description_textarea.fill("Description")
+    language_select = modal.wait_for_selector("#id_language")
+    language_select.select_option("fra")
+    submit_button = modal.wait_for_selector("button[type=submit]")
+    # submit_button.click()
+    # TODO: find the correct Playwright assertion in this context.
diff --git a/integration/test_keywords.py b/integration/test_keywords.py
new file mode 100644
index 0000000000000000000000000000000000000000..fed756964bab13862d1b321ca1b20efd91f8f532
--- /dev/null
+++ b/integration/test_keywords.py
@@ -0,0 +1,10 @@
+def test_submitting_keyword_form(page):
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.71/")
+    page.hover("#keywords h2")
+    page.click("#keywords h2 a >> text='Add'")
+    modal = page.wait_for_selector("#keyword-new")
+    keyword_select = modal.wait_for_selector("#id_keyword")
+    keyword_select.select_option(label="Apollo, Apollon, Phébus / Phœbus")
+    submit_button = modal.wait_for_selector("button[type=submit]")
+    # submit_button.click()
+    # TODO: find the correct Playwright assertion in this context.
diff --git a/integration/test_texts.py b/integration/test_texts.py
new file mode 100644
index 0000000000000000000000000000000000000000..d02ee93c0aba9af96685714b5ec7f38201781e6e
--- /dev/null
+++ b/integration/test_texts.py
@@ -0,0 +1,11 @@
+def test_submitting_text_form(page):
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
+    page.click("#texts-tabs-first a >> text='+ add'")
+    modal = page.wait_for_selector("#text-new")
+    title_input = modal.wait_for_selector("#id_text")
+    title_input.fill("Text content")
+    language_select = modal.wait_for_selector("#id_language")
+    language_select.select_option("fra")
+    submit_button = modal.wait_for_selector("button[type=submit]")
+    # submit_button.click()
+    # TODO: find the correct Playwright assertion in this context.
diff --git a/integration/test_texts_tabs.py b/integration/test_texts_tabs.py
index 2ae8ce6be28a1002a94b2947e93accb34c2baac1..eb69ae2b145e32288cd162d8c1c1d68109a61163 100644
--- a/integration/test_texts_tabs.py
+++ b/integration/test_texts_tabs.py
@@ -1,28 +1,28 @@
 def test_first_tab_is_selected(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
     assert (
-        page.textContent("#texts-tabs-first .tab-container .selected").strip() == "GRC"
+        page.text_content("#texts-tabs-first .tab-container .selected").strip() == "grc"
     )
 
 
 def test_first_content_is_visible(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
     assert (
-        page.textContent("#texts-tabs-first #text-content-first-1-GRC")
+        page.text_content("#texts-tabs-first #text-content-first-1-grc")
         .strip()
         .startswith("νῦν πλέον ἢ τὸ πάροιθε")
     )
 
 
 def test_click_on_french_tab(page):
-    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70")
-    tab = page.waitForSelector('#texts-tabs-first [href="#text-content-first-2-FRA"]')
+    page.goto("/passages/urn:cts:greekLit:tlg7000.tlg001.ag:7.70/")
+    tab = page.wait_for_selector('#texts-tabs-first [href="#text-content-first-4-fra"]')
     tab.click()
     assert (
-        page.textContent("#texts-tabs-first .tab-container .selected").strip() == "FRA"
+        page.text_content("#texts-tabs-first .tab-container .selected").strip() == "fra"
     )
     assert (
-        page.textContent("#texts-tabs-first #text-content-first-2-FRA")
+        page.text_content("#texts-tabs-first #text-content-first-4-fra")
         .strip()
         .startswith("Maintenant plus qu'auparavant")
     )
diff --git a/requirements.txt b/requirements.txt
index 9e087904df311ad33c02a437d289b2fe40a214fa..985591692fcf46087e0ac370eba83317e00fcbb4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,13 +1,15 @@
 Django==3.1.1
 djangorestframework==3.11.1
 django-extensions==3.0.9
+django-filter==2.4.0
 django-guardian==2.3.0
 psycopg2-binary==2.8.6
 django-simple-history==2.12.0
 django-select2==7.4.2
+graphene-django>=2.0.0
 iso-639
 requests
 Sphinx==3.3.0
 sphinxcontrib-django==0.5.1
 sphinx-rtd-theme==0.5.0
-autodocsumm==0.2.1
\ No newline at end of file
+autodocsumm==0.2.1