diff --git a/Tekst-API/openapi.json b/Tekst-API/openapi.json index afb305178..690c2d9c2 100644 --- a/Tekst-API/openapi.json +++ b/Tekst-API/openapi.json @@ -310,7 +310,7 @@ "browse" ], "summary": "Get location data", - "description": "Returns the location path from the location with the given level/position\nas the last element, up to its most distant ancestor location\non structure level 0 as the first element of an array as well as all contents\nfor the given resource(s) referencing the locations in the location path.", + "description": "Returns the location path from the location with the given ID or text/level/position\nas the last element, up to its most distant ancestor location\non structure level 0 as the first element of an array as well as all contents\nfor the given resource(s) referencing the locations in the location path.", "operationId": "getLocationData", "security": [ { @@ -321,42 +321,79 @@ } ], "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9a-f]{24}$", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "description": "ID of location to request data for", + "title": "Id" + }, + "description": "ID of location to request data for" + }, { "name": "txt", "in": "query", - "required": true, + "required": false, "schema": { - "type": "string", - "minLength": 24, - "maxLength": 24, - "pattern": "^[0-9a-f]{24}$", - "description": "ID of text to look up data for", - "example": "5eb7cf5a86d9755df3a6c593", + "anyOf": [ + { + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9a-f]{24}$", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "description": "ID of text the target location belongs to (needed if no location ID is given)", "title": "Txt" }, - "description": "ID of text to look up data for" + "description": "ID of text the target location belongs to (needed if no location ID is given)" }, { "name": "lvl", "in": "query", - "required": true, + "required": false, "schema": { - "type": "integer", - "description": "Location level", + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Location level (only used if no location ID is given, text's default level is used by default)", "title": "Lvl" }, - "description": "Location level" + "description": "Location level (only used if no location ID is given, text's default level is used by default)" }, { "name": "pos", "in": "query", - "required": true, + "required": false, "schema": { "type": "integer", - "description": "Location position", + "description": "Location position (only used if no location ID is given)", + "default": 0, "title": "Pos" }, - "description": "Location position" + "description": "Location position (only used if no location ID is given)" }, { "name": "res", @@ -371,11 +408,11 @@ "pattern": "^[0-9a-f]{24}$", "example": "5eb7cf5a86d9755df3a6c593" }, - "description": "ID (or list of IDs) of resource(s) to return content data for", + "description": "ID (or list of IDs) of resource(s) to return contents for", "default": [], "title": "Res" }, - "description": "ID (or list of IDs) of resource(s) to return content data for" + "description": "ID (or list of IDs) of resource(s) to return contents for" }, { "name": "head", @@ -413,6 +450,16 @@ } } }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + }, + "description": "Not Found" + }, "422": { "description": "Validation Error", "content": { @@ -426,14 +473,14 @@ } } }, - "/browse/nearest-content-position": { + "/browse/nearest-content-location-id": { "get": { "tags": [ "browse" ], - "summary": "Get nearest content position", - "description": "Finds the nearest location the given resource holds content for and returns\nits position index or -1 if no content was found.", - "operationId": "getNearestContentPosition", + "summary": "Get nearest content location id", + "description": "Finds the nearest location the given resource holds content for and returns\nits ID or an empty string if no more content was found.", + "operationId": "getNearestContentLocationId", "security": [ { "APIKeyCookie": [] @@ -444,15 +491,19 @@ ], "parameters": [ { - "name": "pos", + "name": "loc", "in": "query", "required": true, "schema": { - "type": "integer", - "description": "Location position", - "title": "Pos" + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9a-f]{24}$", + "description": "Current content location", + "example": "5eb7cf5a86d9755df3a6c593", + "title": "Loc" }, - "description": "Location position" + "description": "Current content location" }, { "name": "res", @@ -470,20 +521,20 @@ "description": "ID of resource to return nearest location with content for" }, { - "name": "mode", + "name": "dir", "in": "query", "required": false, "schema": { "enum": [ - "preceding", - "subsequent" + "before", + "after" ], "type": "string", - "description": "Whether to look for the nearest preceding or subsequent location with content", - "default": "subsequent", - "title": "Mode" + "description": "Whether to look for the nearest preceding (before) or subsequent (after) location with content", + "default": "after", + "title": "Dir" }, - "description": "Whether to look for the nearest preceding or subsequent location with content" + "description": "Whether to look for the nearest preceding (before) or subsequent (after) location with content" } ], "responses": { @@ -492,8 +543,8 @@ "content": { "application/json": { "schema": { - "type": "integer", - "title": "Response Get Nearest Content Position Browse Nearest Content Position Get" + "type": "string", + "title": "Response Get Nearest Content Location Id Browse Nearest Content Location Id Get" } } } @@ -1238,6 +1289,16 @@ } } }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + } + }, "422": { "description": "Validation Error", "content": { @@ -7669,62 +7730,6 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { - "anyOf": [ - { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, "citation": { "anyOf": [ { @@ -7773,13 +7778,6 @@ "defaultCollapsed": false } } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", @@ -8847,10 +8845,14 @@ "description": "ID of the resource this correction refers to", "example": "5eb7cf5a86d9755df3a6c593" }, - "position": { - "type": "integer", - "title": "Position", - "description": "Position of the content this correction refers to" + "locationId": { + "type": "string", + "maxLength": 24, + "minLength": 24, + "pattern": "^[0-9a-f]{24}$", + "title": "Locationid", + "description": "ID of the location this correction refers to", + "example": "5eb7cf5a86d9755df3a6c593" }, "note": { "type": "string", @@ -8863,7 +8865,7 @@ "type": "object", "required": [ "resourceId", - "position", + "locationId", "note" ], "title": "CorrectionCreate" @@ -8887,20 +8889,15 @@ "description": "ID of the resource this correction refers to", "example": "5eb7cf5a86d9755df3a6c593" }, - "userId": { + "locationId": { "type": "string", "maxLength": 24, "minLength": 24, "pattern": "^[0-9a-f]{24}$", - "title": "Userid", - "description": "ID of the user who created the correction note", + "title": "Locationid", + "description": "ID of the location this correction refers to", "example": "5eb7cf5a86d9755df3a6c593" }, - "position": { - "type": "integer", - "title": "Position", - "description": "Position of the content this correction refers to" - }, "note": { "type": "string", "maxLength": 1000, @@ -8908,6 +8905,20 @@ "title": "Note", "description": "Content of the correction note" }, + "userId": { + "type": "string", + "maxLength": 24, + "minLength": 24, + "pattern": "^[0-9a-f]{24}$", + "title": "Userid", + "description": "ID of the user who created the correction note", + "example": "5eb7cf5a86d9755df3a6c593" + }, + "position": { + "type": "integer", + "title": "Position", + "description": "Position of the correction on the resource's level" + }, "date": { "type": "string", "format": "date-time", @@ -8928,9 +8939,10 @@ "required": [ "id", "resourceId", + "locationId", + "note", "userId", "position", - "note", "date", "locationLabels" ], @@ -9416,62 +9428,6 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { - "anyOf": [ - { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, "citation": { "anyOf": [ { @@ -9520,13 +9476,6 @@ "defaultCollapsed": false } } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", @@ -10515,67 +10464,11 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { + "citation": { "anyOf": [ { "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, - "citation": { - "anyOf": [ - { - "type": "string", - "maxLength": 1000 + "maxLength": 1000 }, { "type": "null" @@ -10619,13 +10512,6 @@ "defaultCollapsed": true } } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", @@ -11152,14 +11038,14 @@ }, "LocationCoverage": { "properties": { + "locId": { + "type": "string", + "title": "Locid" + }, "label": { "type": "string", "title": "Label" }, - "position": { - "type": "integer", - "title": "Position" - }, "covered": { "type": "boolean", "title": "Covered", @@ -11168,8 +11054,8 @@ }, "type": "object", "required": [ - "label", - "position" + "locId", + "label" ], "title": "LocationCoverage" }, @@ -11257,8 +11143,41 @@ }, "type": "array", "title": "Locationpath", + "description": "Path of locations from level 0 to this location", "default": [] }, + "prev": { + "anyOf": [ + { + "type": "string", + "maxLength": 24, + "minLength": 24, + "pattern": "^[0-9a-f]{24}$", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "title": "Prev", + "description": "ID of the preceding location on the same level" + }, + "next": { + "anyOf": [ + { + "type": "string", + "maxLength": 24, + "minLength": 24, + "pattern": "^[0-9a-f]{24}$", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "title": "Next", + "description": "ID of the subsequent location on the same level" + }, "contents": { "items": { "oneOf": [ @@ -11295,6 +11214,7 @@ }, "type": "array", "title": "Contents", + "description": "Contents of various resources on this location", "default": [] } }, @@ -11947,62 +11867,6 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { - "anyOf": [ - { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, "citation": { "anyOf": [ { @@ -12062,13 +11926,6 @@ "enabled": false } } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", @@ -13789,62 +13646,6 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { - "anyOf": [ - { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, "citation": { "anyOf": [ { @@ -13893,13 +13694,6 @@ "defaultCollapsed": true } } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", @@ -14998,62 +14792,6 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { - "anyOf": [ - { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, "citation": { "anyOf": [ { @@ -15104,13 +14842,6 @@ "annotationGroups": [], "multiValueDelimiter": "/" } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", diff --git a/Tekst-API/pyproject.toml b/Tekst-API/pyproject.toml index 6fb6be46a..068e74ed4 100644 --- a/Tekst-API/pyproject.toml +++ b/Tekst-API/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tekst" -version = "0.2.1a0" +version = "0.3.0a0" description = "An online text research platform" readme = "README.md" authors = [ diff --git a/Tekst-API/tekst/db/migrations/__init__.py b/Tekst-API/tekst/db/migrations/__init__.py index 213ba9bdc..11bc366d7 100644 --- a/Tekst-API/tekst/db/migrations/__init__.py +++ b/Tekst-API/tekst/db/migrations/__init__.py @@ -1,7 +1,6 @@ import asyncio from collections.abc import Callable -from dataclasses import dataclass from importlib import import_module from pkgutil import iter_modules @@ -11,33 +10,34 @@ from tekst.logs import log -@dataclass -class Migration: - version: str - proc: Callable[[Database], None] +MigrationsDict = dict[str, Callable[[Database], None]] -def _sort_migrations(migrs: list[Migration]) -> list[Migration]: - return sorted(migrs, key=lambda m: Version(m.version)) +def _sort_migrations(migrations: MigrationsDict) -> MigrationsDict: + return { + key: migrations[key] for key in sorted(migrations, key=lambda m: Version(m)) + } -def _list_migrations() -> list[Migration]: - return _sort_migrations( - [ - import_module(f"{__name__}.{mod.name}").MIGRATION - for mod in iter_modules(__path__) - ] - ) +def _read_migrations() -> MigrationsDict: + return { + mod.name.replace("migration_", "").replace("_", "."): import_module( + f"{__name__}.{mod.name}" + ).migration + for mod in iter_modules(__path__) + } -_MIGRATIONS = _list_migrations() +_MIGRATIONS = _read_migrations() async def _is_migration_pending(db_version: str) -> bool: if db_version is None: log.warning("No DB version found. Has setup been run?") return False - return bool(_MIGRATIONS and Version(db_version) < Version(_MIGRATIONS[-1].version)) + return bool( + _MIGRATIONS and Version(db_version) < Version(list(_MIGRATIONS.keys())[-1]) + ) async def check_db_version(db_version: str, auto_migrate: bool = False) -> None: @@ -92,18 +92,18 @@ async def migrate() -> None: # run relevant migrations curr_db_version = db_version_before - for migration in _MIGRATIONS: - if Version(migration.version) > Version(curr_db_version): - log.debug(f"Migrating DB from {curr_db_version} to {migration.version}...") + for migration_v in _MIGRATIONS: + if Version(migration_v) > Version(curr_db_version): + log.debug(f"Migrating DB from {curr_db_version} to {migration_v}...") try: - await migration.proc(db) + await _MIGRATIONS[migration_v](db) except Exception as e: # pragma: no cover log.error( f"Failed migrating DB from {curr_db_version} " - f"to {migration.version}: {e}" + f"to {migration_v}: {e}" ) raise e - curr_db_version = migration.version + curr_db_version = migration_v # update DB version in state document await state_coll.update_one( diff --git a/Tekst-API/tekst/db/migrations/0_1_0_a0.py b/Tekst-API/tekst/db/migrations/migration_0_1_0a0.py similarity index 52% rename from Tekst-API/tekst/db/migrations/0_1_0_a0.py rename to Tekst-API/tekst/db/migrations/migration_0_1_0a0.py index 692b640c4..acfd5bf27 100644 --- a/Tekst-API/tekst/db/migrations/0_1_0_a0.py +++ b/Tekst-API/tekst/db/migrations/migration_0_1_0a0.py @@ -1,15 +1,8 @@ from tekst.db import Database -from tekst.db.migrations import Migration -async def _proc(db: Database) -> None: +async def migration(db: Database) -> None: """ This migration procedre is a no-op since it belongs to the very first version handling migrations at all. """ - - -MIGRATION = Migration( - version="0.1.0a0", - proc=_proc, -) diff --git a/Tekst-API/tekst/db/migrations/0_2_0_a0.py b/Tekst-API/tekst/db/migrations/migration_0_2_0a0.py similarity index 54% rename from Tekst-API/tekst/db/migrations/0_2_0_a0.py rename to Tekst-API/tekst/db/migrations/migration_0_2_0a0.py index 1e6f15367..fc6933b22 100644 --- a/Tekst-API/tekst/db/migrations/0_2_0_a0.py +++ b/Tekst-API/tekst/db/migrations/migration_0_2_0a0.py @@ -1,13 +1,6 @@ from tekst.db import Database -from tekst.db.migrations import Migration -async def _proc(db: Database) -> None: +async def migration(db: Database) -> None: # rename field "custom_fonts" in "state" collection to "fonts" await db.state.update_many({}, {"$rename": {"custom_fonts": "fonts"}}) - - -MIGRATION = Migration( - version="0.2.0a0", - proc=_proc, -) diff --git a/Tekst-API/tekst/db/migrations/0_2_1_a0.py b/Tekst-API/tekst/db/migrations/migration_0_2_1a0.py similarity index 52% rename from Tekst-API/tekst/db/migrations/0_2_1_a0.py rename to Tekst-API/tekst/db/migrations/migration_0_2_1a0.py index 999af6d6a..b15207122 100644 --- a/Tekst-API/tekst/db/migrations/0_2_1_a0.py +++ b/Tekst-API/tekst/db/migrations/migration_0_2_1a0.py @@ -1,13 +1,6 @@ from tekst.db import Database -from tekst.db.migrations import Migration -async def _proc(db: Database) -> None: +async def migration(db: Database) -> None: # delete platform state field "register_intro_text" await db.state.update_many({}, {"$unset": {"register_intro_text": ""}}) - - -MIGRATION = Migration( - version="0.2.1a0", - proc=_proc, -) diff --git a/Tekst-API/tekst/db/migrations/migration_0_3_0a0.py b/Tekst-API/tekst/db/migrations/migration_0_3_0a0.py new file mode 100644 index 000000000..8bad3ac78 --- /dev/null +++ b/Tekst-API/tekst/db/migrations/migration_0_3_0a0.py @@ -0,0 +1,18 @@ +from tekst.db import Database + + +async def migration(db: Database) -> None: + # add location ID to all correction model instances + resources_by_id = {res["_id"]: res for res in await db.resources.find({}).to_list()} + async for corr in db.corrections.find({}): + res = resources_by_id[corr["resource_id"]] + loc = await db.locations.find_one( + { + "text_id": res["text_id"], + "level": res["level"], + "position": corr["position"], + } + ) + await db.corrections.update_one( + {"_id": corr["_id"]}, {"$set": {"location_id": loc["_id"]}} + ) diff --git a/Tekst-API/tekst/errors.py b/Tekst-API/tekst/errors.py index 3421cdcc2..9ac2809d8 100644 --- a/Tekst-API/tekst/errors.py +++ b/Tekst-API/tekst/errors.py @@ -310,7 +310,10 @@ def update_values( E_400_INVALID_LEVEL = error_instance( status_code=status.HTTP_400_BAD_REQUEST, key="locationInvalidLevel", - msg="The level index passed does not exist in target text", + msg=( + "The level index passed is invalid or doesn't " + "match the level of a referenced object" + ), ) E_400_LOCATION_NO_LEVEL_NOR_PARENT = error_instance( diff --git a/Tekst-API/tekst/models/browse.py b/Tekst-API/tekst/models/browse.py index d5837ca62..cf22519e4 100644 --- a/Tekst-API/tekst/models/browse.py +++ b/Tekst-API/tekst/models/browse.py @@ -1,8 +1,37 @@ +from typing import Annotated + +from beanie import PydanticObjectId +from pydantic import Field + from tekst.models.common import ModelBase from tekst.models.location import LocationRead from tekst.resources import AnyContentRead class LocationData(ModelBase): - location_path: list[LocationRead] = [] - contents: list[AnyContentRead] = [] + location_path: Annotated[ + list[LocationRead], + Field( + description="Path of locations from level 0 to this location", + ), + ] = [] + previous_loc_id: Annotated[ + PydanticObjectId | None, + Field( + description="ID of the preceding location on the same level", + alias="prev", + ), + ] = None + next_loc_id: Annotated[ + PydanticObjectId | None, + Field( + description="ID of the subsequent location on the same level", + alias="next", + ), + ] = None + contents: Annotated[ + list[AnyContentRead], + Field( + description="Contents of various resources on this location", + ), + ] = [] diff --git a/Tekst-API/tekst/models/correction.py b/Tekst-API/tekst/models/correction.py index 171f7068a..615261bee 100644 --- a/Tekst-API/tekst/models/correction.py +++ b/Tekst-API/tekst/models/correction.py @@ -5,6 +5,7 @@ from tekst.models.common import ( DocumentBase, + ExcludeFromModelVariants, ModelBase, ModelFactoryMixin, PydanticObjectId, @@ -19,16 +20,10 @@ class Correction(ModelBase, ModelFactoryMixin): description="ID of the resource this correction refers to", ), ] - user_id: Annotated[ + location_id: Annotated[ PydanticObjectId, Field( - description="ID of the user who created the correction note", - ), - ] - position: Annotated[ - int, - Field( - description="Position of the content this correction refers to", + description="ID of the location this correction refers to", ), ] note: Annotated[ @@ -43,44 +38,33 @@ class Correction(ModelBase, ModelFactoryMixin): ), val.CleanupMultiline, ] - date: Annotated[ - datetime, - Field( - description="Date when the correction was created", - ), - ] - location_labels: Annotated[ - list[str], - Field( - description="Text location labels from root to target location", - ), - ] - - -class CorrectionCreate(ModelBase): - resource_id: Annotated[ + user_id: Annotated[ PydanticObjectId, Field( - description="ID of the resource this correction refers to", + description="ID of the user who created the correction note", ), + ExcludeFromModelVariants(create=True), ] position: Annotated[ int, Field( - description="Position of the content this correction refers to", + description="Position of the correction on the resource's level", ), + ExcludeFromModelVariants(create=True), ] - note: Annotated[ - str, + date: Annotated[ + datetime, Field( - description="Content of the correction note", + description="Date when the correction was created", ), - StringConstraints( - min_length=1, - max_length=1000, - strip_whitespace=True, + ExcludeFromModelVariants(create=True), + ] + location_labels: Annotated[ + list[str], + Field( + description="Text location labels from root to target location", ), - val.CleanupMultiline, + ExcludeFromModelVariants(create=True), ] @@ -89,8 +73,9 @@ class Settings(DocumentBase.Settings): name = "corrections" indexes = [ "resource_id", - "position", + "location_id", ] +CorrectionCreate = Correction.create_model() CorrectionRead = Correction.read_model() diff --git a/Tekst-API/tekst/models/resource.py b/Tekst-API/tekst/models/resource.py index 70c28b41e..abd2c1410 100644 --- a/Tekst-API/tekst/models/resource.py +++ b/Tekst-API/tekst/models/resource.py @@ -154,6 +154,7 @@ class ResourceBase(ModelBase, ModelFactoryMixin): ), ExcludeFromModelVariants( update=True, + create=True, ), ] = None @@ -163,6 +164,9 @@ class ResourceBase(ModelBase, ModelFactoryMixin): description="Users with shared read access to this resource", max_length=64, ), + ExcludeFromModelVariants( + create=True, + ), ] = [] shared_write: Annotated[ @@ -171,6 +175,9 @@ class ResourceBase(ModelBase, ModelFactoryMixin): description="Users with shared write access to this resource", max_length=64, ), + ExcludeFromModelVariants( + create=True, + ), ] = [] public: Annotated[ @@ -180,6 +187,7 @@ class ResourceBase(ModelBase, ModelFactoryMixin): ), ExcludeFromModelVariants( update=True, + create=True, ), ] = False @@ -190,6 +198,7 @@ class ResourceBase(ModelBase, ModelFactoryMixin): ), ExcludeFromModelVariants( update=True, + create=True, ), ] = False @@ -230,6 +239,7 @@ class ResourceBase(ModelBase, ModelFactoryMixin): ), ExcludeFromModelVariants( update=True, + create=True, ), ] = datetime.utcfromtimestamp(86400) @@ -395,13 +405,14 @@ async def __precompute_coverage_data(self) -> None: coverage_per_parent = {} covered_locations_count = 0 for location in data: + loc_id = str(location["id"]) parent_id = str(location["parent_id"]) if parent_id not in coverage_per_parent: coverage_per_parent[parent_id] = [] coverage_per_parent[parent_id].append( { - "label": location_labels[str(location["id"])], - "position": location["position"], + "loc_id": loc_id, + "label": location_labels[loc_id], "covered": location["covered"], } ) @@ -562,8 +573,8 @@ class ResourceReadExtras(ModelBase): class LocationCoverage(ModelBase): + loc_id: str label: str - position: int covered: bool = False diff --git a/Tekst-API/tekst/routers/browse.py b/Tekst-API/tekst/routers/browse.py index 507784c6c..144323210 100644 --- a/Tekst-API/tekst/routers/browse.py +++ b/Tekst-API/tekst/routers/browse.py @@ -15,6 +15,7 @@ from tekst.models.resource import ( ResourceBaseDocument, ) +from tekst.models.text import TextDocument from tekst.resources import AnyContentRead @@ -111,20 +112,53 @@ async def get_content_siblings( "/location-data", response_model=LocationData, status_code=status.HTTP_200_OK, + responses=errors.responses( + [ + errors.E_404_LOCATION_NOT_FOUND, + ] + ), ) async def get_location_data( user: OptionalUserDep, + location_id: Annotated[ + PydanticObjectId | None, + Query( + alias="id", + description="ID of location to request data for", + ), + ] = None, text_id: Annotated[ - PydanticObjectId, - Query(alias="txt", description="ID of text to look up data for"), - ], - level: Annotated[int, Query(alias="lvl", description="Location level")], - position: Annotated[int, Query(alias="pos", description="Location position")], + PydanticObjectId | None, + Query( + alias="txt", + description=( + "ID of text the target location belongs to " + "(needed if no location ID is given)" + ), + ), + ] = None, + level: Annotated[ + int | None, + Query( + alias="lvl", + description=( + "Location level (only used if no location ID is given, " + "text's default level is used by default)" + ), + ), + ] = None, + position: Annotated[ + int, + Query( + alias="pos", + description="Location position (only used if no location ID is given)", + ), + ] = 0, resource_ids: Annotated[ list[PydanticObjectId], Query( alias="res", - description="ID (or list of IDs) of resource(s) to return content data for", + description="ID (or list of IDs) of resource(s) to return contents for", ), ] = [], only_head_contents: Annotated[ @@ -134,21 +168,35 @@ async def get_location_data( description="Only return contents for the head location of the path", ), ] = False, - limit: Annotated[int, Query(description="Return at most contents")] = 4096, + limit: Annotated[ + int, + Query( + description="Return at most contents", + ), + ] = 4096, ) -> LocationData: """ - Returns the location path from the location with the given level/position + Returns the location path from the location with the given ID or text/level/position as the last element, up to its most distant ancestor location on structure level 0 as the first element of an array as well as all contents for the given resource(s) referencing the locations in the location path. """ - location_doc = await LocationDocument.find_one( - LocationDocument.text_id == text_id, - LocationDocument.level == level, - LocationDocument.position == position, - ) + # find target location + location_doc = None + if location_id: + location_doc = await LocationDocument.get(location_id) + elif text_id: + text_doc = await TextDocument.get(text_id) + if text_doc: + lvl = level if level is not None else text_doc.default_level + location_doc = await LocationDocument.find_one( + LocationDocument.text_id == text_id, + LocationDocument.level == lvl, + LocationDocument.position == position or 0, + ) if not location_doc: - return LocationData() + raise errors.E_404_LOCATION_NOT_FOUND + # construct path up to root location location_path = [location_doc] parent_id = location_doc.parent_id @@ -181,12 +229,45 @@ async def get_location_data( .to_list() ) - # return combined data as LocationData - return LocationData(location_path=location_path, contents=content_docs) + # return combined data as LocationData, + # including IDs of preceding and subsequent locations + prev_loc = ( + await LocationDocument.find_one( + LocationDocument.text_id == location_doc.text_id, + LocationDocument.level == location_doc.level, + LocationDocument.position == location_doc.position - 1, + ) + ) or ( + await LocationDocument.find( + LocationDocument.text_id == location_doc.text_id, + LocationDocument.level == location_doc.level, + ) + .sort(-LocationDocument.position) + .first_or_none() + ) + next_loc = ( + await LocationDocument.find_one( + LocationDocument.text_id == location_doc.text_id, + LocationDocument.level == location_doc.level, + LocationDocument.position == location_doc.position + 1, + ) + ) or ( + await LocationDocument.find_one( + LocationDocument.text_id == location_doc.text_id, + LocationDocument.level == location_doc.level, + LocationDocument.position == 0, + ) + ) + return LocationData( + location_path=location_path, + previous_loc_id=prev_loc.id if prev_loc else None, + next_loc_id=next_loc.id if next_loc else None, + contents=content_docs, + ) @router.get( - "/nearest-content-position", + "/nearest-content-location-id", status_code=status.HTTP_200_OK, responses=errors.responses( [ @@ -194,9 +275,15 @@ async def get_location_data( ] ), ) -async def get_nearest_content_position( +async def get_nearest_content_location_id( user: OptionalUserDep, - position: Annotated[int, Query(alias="pos", description="Location position")], + location_id: Annotated[ + PydanticObjectId, + Query( + alias="loc", + description="ID of the location to start from", + ), + ], resource_id: Annotated[ PydanticObjectId, Query( @@ -204,45 +291,57 @@ async def get_nearest_content_position( description="ID of resource to return nearest location with content for", ), ], - mode: Annotated[ - Literal["preceding", "subsequent"], + direction: Annotated[ + Literal["before", "after"], Query( + alias="dir", description=( - "Whether to look for the nearest preceding " - "or subsequent location with content" - ) + "Whether to look for the nearest preceding (before) " + "or subsequent (after) location with content" + ), ), - ] = "subsequent", -) -> int: + ] = "after", +) -> str: """ Finds the nearest location the given resource holds content for and returns - its position index or -1 if no content was found. + its ID or an empty string if no more content was found. """ - # we don't check read access here, because are passing data to the location-data - # endpoint later anyway and it already checks for permissions - resource_doc = await ResourceBaseDocument.get(resource_id, with_children=True) + resource_doc = await ResourceBaseDocument.find_one( + ResourceBaseDocument.id == resource_id, + await ResourceBaseDocument.access_conditions_read(user), + with_children=True, + ) if not resource_doc: raise errors.E_404_RESOURCE_NOT_FOUND + location_doc = await LocationDocument.get(location_id) + if not location_doc: + raise errors.E_404_LOCATION_NOT_FOUND + if ( + location_doc.level != resource_doc.level + or location_doc.text_id != resource_doc.text_id + ): + raise errors.E_400_INVALID_REQUEST_DATA + # get all locations before/after said location locations = ( await LocationDocument.find( LocationDocument.text_id == resource_doc.text_id, LocationDocument.level == resource_doc.level, - (LocationDocument.position < position) - if mode == "preceding" - else (LocationDocument.position > position), + (LocationDocument.position < location_doc.position) + if direction == "before" + else (LocationDocument.position > location_doc.position), ) .sort( +LocationDocument.position - if mode == "subsequent" + if direction == "after" else -LocationDocument.position ) .aggregate([{"$project": {"position": 1}}]) .to_list() ) if not locations: # pragma: no cover - return -1 + return "" # get contents for these locations contents = ( @@ -258,7 +357,7 @@ async def get_nearest_content_position( .to_list() ) if not contents: # pragma: no cover - return -1 + return "" # find out nearest of those locations with contents locations = [ @@ -267,8 +366,11 @@ async def get_nearest_content_position( if location.get("_id") in [content.get("location_id") for content in contents] ] - # return position of nearest location with contents of the target resource - return locations[0].get("position") + if len(locations) == 0: # pragma: no cover + return "" + + # return ID of nearest location with contents of the target resource + return str(locations[0].get("_id")) @router.get( diff --git a/Tekst-API/tekst/routers/corrections.py b/Tekst-API/tekst/routers/corrections.py index 61091ce1f..4b5509452 100644 --- a/Tekst-API/tekst/routers/corrections.py +++ b/Tekst-API/tekst/routers/corrections.py @@ -31,6 +31,7 @@ [ errors.E_404_RESOURCE_NOT_FOUND, errors.E_404_CONTENT_NOT_FOUND, + errors.E_400_INVALID_LEVEL, ] ), ) @@ -50,13 +51,14 @@ async def create_correction( raise errors.E_404_RESOURCE_NOT_FOUND # get location, check if it is valid - location_doc = await LocationDocument.find_one( - LocationDocument.text_id == resource_doc.text_id, - LocationDocument.level == resource_doc.level, - LocationDocument.position == correction.position, - ) + location_doc = await LocationDocument.get(correction.location_id) if not location_doc: raise errors.E_404_CONTENT_NOT_FOUND + if ( + resource_doc.level != location_doc.level + or resource_doc.text_id != location_doc.text_id + ): + raise errors.E_400_INVALID_REQUEST_DATA # construct full label location_labels = [location_doc.label] @@ -96,9 +98,10 @@ async def create_correction( # create correction return await CorrectionDocument( resource_id=correction.resource_id, - user_id=user.id, - position=correction.position, + location_id=correction.location_id, + position=location_doc.position, note=correction.note, + user_id=user.id, date=datetime.utcnow(), location_labels=location_labels, ).create() diff --git a/Tekst-API/tekst/routers/resources.py b/Tekst-API/tekst/routers/resources.py index 2a064d6ff..4debd08d5 100644 --- a/Tekst-API/tekst/routers/resources.py +++ b/Tekst-API/tekst/routers/resources.py @@ -167,21 +167,16 @@ async def create_resource( if resource.level > len(text.levels) - 1: raise errors.E_400_RESOURCE_INVALID_LEVEL - # force some values on creation - resource.owner_id = user.id - resource.proposed = False - resource.public = False - resource.shared_read = [] - resource.shared_write = [] - # find document model for this resource type, instantiate, create resource_doc = ( - await resource_types_mgr.get(resource.resource_type) + resource_types_mgr.get(resource.resource_type) .resource_model() .document_model() .model_from(resource) - .create() ) + resource_doc.owner_id = user.id # set correct owner ID + await resource_doc.create() # create resource in DB + return await preprocess_resource_read(resource_doc, user) diff --git a/Tekst-API/tests/conftest.py b/Tekst-API/tests/conftest.py index 273b02a37..5da97c27f 100644 --- a/Tekst-API/tests/conftest.py +++ b/Tekst-API/tests/conftest.py @@ -83,7 +83,7 @@ def _get_sample_data( @pytest.fixture(scope="session") -async def get_db_client_override(config) -> db.DatabaseClient: +async def db_client_override(config) -> db.DatabaseClient: """Dependency override for the database client dependency""" db_client = db.DatabaseClient(config.db.uri) yield db_client @@ -91,17 +91,26 @@ async def get_db_client_override(config) -> db.DatabaseClient: db_client.close() +@pytest.fixture(scope="session") +async def database( + config, + db_client_override, +) -> db.Database: + """DB driver for test session""" + yield db_client_override[config.db.name] + + @pytest.fixture(scope="session") async def test_app( config, - get_db_client_override, + db_client_override, ): """Provides an app instance with overridden dependencies""" - app.dependency_overrides[db.get_db_client] = lambda: get_db_client_override + app.dependency_overrides[db.get_db_client] = lambda: db_client_override async with LifespanManager(app): yield app # cleanup data - await get_db_client_override.drop_database(config.db.name) + await db_client_override.drop_database(config.db.name) @pytest.fixture @@ -126,9 +135,8 @@ async def test_client( @pytest.fixture async def insert_sample_data( - config, + database, get_sample_data, - get_db_client_override, ) -> Callable: """ Returns an asynchronous function to insert @@ -136,7 +144,6 @@ async def insert_sample_data( """ async def _insert_sample_data(*collections: str) -> dict[str, list[str]]: - database = get_db_client_override[config.db.name] ids = dict() collections = collections or [ "contents", @@ -188,16 +195,11 @@ def _get_fake_user(suffix: str = ""): @pytest.fixture(autouse=True) -async def setup_teardown( - config, - get_db_client_override, -) -> Callable: +async def setup_teardown(database) -> Callable: yield # drop all DB collections - for collection in await get_db_client_override[ - config.db.name - ].list_collection_names(): - await get_db_client_override[config.db.name].drop_collection(collection) + for collection in await database.list_collection_names(): + await database.drop_collection(collection) @pytest.fixture diff --git a/Tekst-API/tests/test_api_browse.py b/Tekst-API/tests/test_api_browse.py index 9daf6359a..897f89670 100644 --- a/Tekst-API/tests/test_api_browse.py +++ b/Tekst-API/tests/test_api_browse.py @@ -87,15 +87,19 @@ async def test_get_location_data( assert len(resp.json()["locationPath"]) > 0 assert len(resp.json()["contents"]) > 0 - # invalid location data + # fail w/ invalid text ID resp = await test_client.get( "/browse/location-data", params={"txt": wrong_id, "lvl": 1, "pos": 0}, ) - assert_status(200, resp) - assert isinstance(resp.json(), dict) - assert len(resp.json()["locationPath"]) == 0 - assert len(resp.json()["contents"]) == 0 + assert_status(404, resp) + + # fail w/ invalid location ID + resp = await test_client.get( + "/browse/location-data", + params={"txt": text_id, "id": wrong_id}, + ) + assert_status(404, resp) @pytest.mark.anyio @@ -162,24 +166,84 @@ async def test_get_nearest_content_position( wrong_id, login, ): - inserted_ids = await insert_sample_data( - "texts", "locations", "resources", "contents" - ) - resource_id = inserted_ids["resources"][0] + await insert_sample_data() + resource = await ResourceBaseDocument.get( + "654b825533ee5737b297f8f3", + with_children=True, + ) + location = ( + await LocationDocument.find( + LocationDocument.level == resource.level, + LocationDocument.text_id == resource.text_id, + ) + .sort(+LocationDocument.position) + .first_or_none() + ) + res_id = str(resource.id) + loc_id = str(location.id) await login() # get nearest content position resp = await test_client.get( - "/browse/nearest-content-position", - params={"res": resource_id, "pos": 0, "mode": "subsequent"}, + "/browse/nearest-content-location-id", + params={ + "res": res_id, + "loc": loc_id, + "dir": "after", + }, ) assert_status(200, resp) - assert isinstance(resp.json(), int) - assert resp.json() == 1 + assert isinstance(resp.json(), str) # fail to get nearest content position with wrong resource ID resp = await test_client.get( - "/browse/nearest-content-position", - params={"res": wrong_id, "pos": 0, "mode": "subsequent"}, + "/browse/nearest-content-location-id", + params={ + "res": wrong_id, + "loc": loc_id, + "dir": "after", + }, ) assert_status(404, resp) + + # fail to get nearest content position with wrong location ID + resp = await test_client.get( + "/browse/nearest-content-location-id", + params={ + "res": res_id, + "loc": wrong_id, + "dir": "after", + }, + ) + assert_status(404, resp) + + # fail to get nearest content position with location + # from different level then resource + location_wrong_level = await LocationDocument.find_one( + LocationDocument.level != resource.level, + LocationDocument.text_id == resource.text_id, + ) + resp = await test_client.get( + "/browse/nearest-content-location-id", + params={ + "res": res_id, + "loc": str(location_wrong_level.id), + "dir": "after", + }, + ) + assert_status(400, resp) + + # fail to get nearest content position with location + # from different text then resource + location_wrong_text = await LocationDocument.find_one( + LocationDocument.text_id != resource.text_id, + ) + resp = await test_client.get( + "/browse/nearest-content-location-id", + params={ + "res": res_id, + "loc": str(location_wrong_text.id), + "dir": "after", + }, + ) + assert_status(400, resp) diff --git a/Tekst-API/tests/test_api_corrections.py b/Tekst-API/tests/test_api_corrections.py index 9805ed252..672daea1f 100644 --- a/Tekst-API/tests/test_api_corrections.py +++ b/Tekst-API/tests/test_api_corrections.py @@ -2,6 +2,7 @@ from beanie import PydanticObjectId from httpx import AsyncClient +from tekst.models.location import LocationDocument from tekst.models.resource import ResourceBaseDocument @@ -15,7 +16,12 @@ async def test_corrections_crud( ): await insert_sample_data() resource = await ResourceBaseDocument.find_one(with_children=True) + location = await LocationDocument.find_one( + LocationDocument.level == resource.level, + LocationDocument.text_id == resource.text_id, + ) res_id = str(resource.id) + loc_id = str(location.id) u = await login() # create correction note on public resource @@ -23,7 +29,7 @@ async def test_corrections_crud( "/corrections", json={ "resourceId": res_id, - "position": 0, + "locationId": loc_id, "note": "Something is wrong here.", }, ) @@ -34,23 +40,52 @@ async def test_corrections_crud( "/corrections", json={ "resourceId": wrong_id, - "position": 0, + "locationId": loc_id, "note": "Something is wrong here.", }, ) assert_status(404, resp) - # fail to create correction note for invalid location + # fail to create correction note for invalid location ID resp = await test_client.post( "/corrections", json={ "resourceId": res_id, - "position": 9999, + "locationId": wrong_id, "note": "Something is wrong here.", }, ) assert_status(404, resp) + # fail to create correction note for invalid location level + location_wrong_level = await LocationDocument.find_one( + LocationDocument.level != resource.level, + LocationDocument.text_id == resource.text_id, + ) + resp = await test_client.post( + "/corrections", + json={ + "resourceId": res_id, + "locationId": str(location_wrong_level.id), + "note": "Something is wrong here.", + }, + ) + assert_status(400, resp) + + # fail to create correction note for location from different text + location_wrong_text = await LocationDocument.find_one( + LocationDocument.text_id != resource.text_id, + ) + resp = await test_client.post( + "/corrections", + json={ + "resourceId": res_id, + "locationId": str(location_wrong_text.id), + "note": "Something is wrong here.", + }, + ) + assert_status(400, resp) + # fail to get correction notes (because of missing permissions) resp = await test_client.get(f"/corrections/{res_id}") assert_status(404, resp) @@ -88,7 +123,7 @@ async def test_corrections_crud( "/corrections", json={ "resourceId": res_id, - "position": 0, + "locationId": loc_id, "note": "This should trigger a notification to the resource owner.", }, ) diff --git a/Tekst-API/tests/test_migrations.py b/Tekst-API/tests/test_migrations.py new file mode 100644 index 000000000..e03496510 --- /dev/null +++ b/Tekst-API/tests/test_migrations.py @@ -0,0 +1,50 @@ +import pytest + +from bson import ObjectId +from tekst.db import migrations + + +@pytest.mark.anyio +async def test_0_1_0a0(database): + await migrations.migration_0_1_0a0.migration(database) # no-op + + +@pytest.mark.anyio +async def test_0_2_0a0(database): + await database.state.insert_one({"custom_fonts": ["foo", "bar"]}) + await migrations.migration_0_2_0a0.migration(database) + state = await database.state.find_one({}) + assert "fonts" in state + assert "custom_fonts" not in state + assert state.get("fonts")[1] == "bar" + + +@pytest.mark.anyio +async def test_0_2_1a0(database): + await database.state.insert_one({"register_intro_text": ["foo", "bar"]}) + await migrations.migration_0_2_1a0.migration(database) + state = await database.state.find_one({}) + assert "register_intro_text" not in state + assert len(state.keys()) == 1 + assert "_id" in state + + +@pytest.mark.anyio +async def test_0_3_0a0( + database, + insert_sample_data, + wrong_id, +): + resource_id = (await insert_sample_data())["resources"][0] + await database.corrections.insert_one( + { + "resource_id": ObjectId(resource_id), + "note": "foo", + "user_id": ObjectId(wrong_id), + "position": 0, + } + ) + await migrations.migration_0_3_0a0.migration(database) + correction = await database.corrections.find_one({}) + assert correction + assert "location_id" in correction diff --git a/Tekst-API/tests/test_setup.py b/Tekst-API/tests/test_setup.py index 1c8aa61b0..5cde489ea 100644 --- a/Tekst-API/tests/test_setup.py +++ b/Tekst-API/tests/test_setup.py @@ -26,11 +26,10 @@ async def test_setup_auto_migrate_no_pending( @pytest.mark.anyio async def test_setup_auto_migrate_pending( config, - get_db_client_override, + database, insert_sample_data, ): await insert_sample_data() # need sample data, as an empty DB will not be migrated - database = get_db_client_override[config.db.name] # set bugus DB data version to 0.0.0 await database["state"].update_one({}, {"$set": {"db_version": "0.0.0"}}) # run app setup with auto_migrate == True (with no pending migrations) @@ -40,12 +39,10 @@ async def test_setup_auto_migrate_pending( @pytest.mark.anyio async def test_migrate_no_state_coll( - config, - get_db_client_override, + database, insert_sample_data, ): await insert_sample_data() # need sample data, as an empty DB will not be migrated - database = get_db_client_override[config.db.name] # drop state collection to test failing migration with missing state await database.drop_collection("state") await migrations.migrate() diff --git a/Tekst-API/tests/test_versions.py b/Tekst-API/tests/test_versions.py index e8e6c08e9..bdafd874c 100644 --- a/Tekst-API/tests/test_versions.py +++ b/Tekst-API/tests/test_versions.py @@ -1,7 +1,7 @@ import re from tekst import __version__ -from tekst.db.migrations import _list_migrations +from tekst.db.migrations import _read_migrations # taken from https://github.com/pypa/packaging/blob/24.1/src/packaging/version.py#L117 @@ -47,21 +47,21 @@ def test_version_pep440(): def test_migration_versions_pep440(): - for migration in _list_migrations(): - assert bool(re.match(_PEP440_VERSION_REGEX, migration.version)) + for migration_version in _read_migrations(): + assert bool(re.match(_PEP440_VERSION_REGEX, migration_version)) def test_migration_sorting(): - from tekst.db.migrations import Migration, _sort_migrations + from tekst.db.migrations import _sort_migrations - migrations = [ - Migration(version="10.12.4a0", proc=lambda: 6), - Migration(version="1.2.30", proc=lambda: 5), - Migration(version="0.1.0", proc=lambda: 2), - Migration(version="1.2.4", proc=lambda: 4), - Migration(version="0.1.0a1", proc=lambda: 1), - Migration(version="1.2.3", proc=lambda: 3), - Migration(version="0.1.0a0", proc=lambda: 0), - ] - proc_results = [m.proc() for m in _sort_migrations(migrations)] - assert proc_results == sorted(proc_results) + migrations = { + "10.12.4a0": lambda: 6, + "1.2.30": lambda: 5, + "0.1.0": lambda: 2, + "1.2.4": lambda: 4, + "0.1.0a1": lambda: 1, + "1.2.3": lambda: 3, + "0.1.0a0": lambda: 0, + } + migration_results = [migrations[ver]() for ver in _sort_migrations(migrations)] + assert migration_results == sorted(migration_results) diff --git a/Tekst-API/uv.lock b/Tekst-API/uv.lock index c0a73fc28..c8488f5bc 100644 --- a/Tekst-API/uv.lock +++ b/Tekst-API/uv.lock @@ -1223,7 +1223,7 @@ wheels = [ [[package]] name = "tekst" -version = "0.2.1a0" +version = "0.3.0a0" source = { editable = "." } dependencies = [ { name = "beanie" }, diff --git a/Tekst-Web/package-lock.json b/Tekst-Web/package-lock.json index 00a66078f..6cc1eb264 100644 --- a/Tekst-Web/package-lock.json +++ b/Tekst-Web/package-lock.json @@ -1,12 +1,12 @@ { "name": "tekst", - "version": "0.2.1-alpha.0", + "version": "0.3.0-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tekst", - "version": "0.2.1-alpha.0", + "version": "0.3.0-alpha.0", "dependencies": { "@tiptap/extension-character-count": "^2.7.1", "@tiptap/extension-image": "^2.7.1", diff --git a/Tekst-Web/package.json b/Tekst-Web/package.json index 0103e07ec..1914faa67 100644 --- a/Tekst-Web/package.json +++ b/Tekst-Web/package.json @@ -1,6 +1,6 @@ { "name": "tekst", - "version": "0.2.1-alpha.0", + "version": "0.3.0-alpha.0", "private": true, "type": "module", "scripts": { diff --git a/Tekst-Web/src/api/index.ts b/Tekst-Web/src/api/index.ts index d220bf2d9..ba46349ce 100644 --- a/Tekst-Web/src/api/index.ts +++ b/Tekst-Web/src/api/index.ts @@ -30,10 +30,11 @@ const interceptors: Middleware = { // automatically log out on a 401 response if (!response.url.endsWith('/logout')) { const { message } = useMessages(); - message.warning($t('account.sessionExpired')); + message.error($t('errors.noAccess', { to: response.url || '/' })); console.log("Oh no! You don't seem to have access to this resource!"); const auth = useAuthStore(); if (auth.loggedIn) { + message.warning($t('account.sessionExpired')); console.log('Running logout sequence in reaction to 401/403 response...'); await auth.logout(true); } @@ -218,6 +219,10 @@ export type ResourceExportFormat = NonNullable< NonNullable['format'] >; +// browse + +export type LocationDataQuery = paths['/browse/location-data']['get']['parameters']['query']; + // bookmark export type BookmarkRead = components['schemas']['BookmarkRead']; diff --git a/Tekst-Web/src/api/schema.d.ts b/Tekst-Web/src/api/schema.d.ts index 0eb1770a7..45116b399 100644 --- a/Tekst-Web/src/api/schema.d.ts +++ b/Tekst-Web/src/api/schema.d.ts @@ -74,7 +74,7 @@ export interface paths { }; /** * Get location data - * @description Returns the location path from the location with the given level/position + * @description Returns the location path from the location with the given ID or text/level/position * as the last element, up to its most distant ancestor location * on structure level 0 as the first element of an array as well as all contents * for the given resource(s) referencing the locations in the location path. @@ -88,7 +88,7 @@ export interface paths { patch?: never; trace?: never; }; - '/browse/nearest-content-position': { + '/browse/nearest-content-location-id': { parameters: { query?: never; header?: never; @@ -96,11 +96,11 @@ export interface paths { cookie?: never; }; /** - * Get nearest content position + * Get nearest content location id * @description Finds the nearest location the given resource holds content for and returns - * its position index or -1 if no content was found. + * its ID or an empty string if no more content was found. */ - get: operations['getNearestContentPosition']; + get: operations['getNearestContentLocationId']; put?: never; post?: never; delete?: never; @@ -1502,35 +1502,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -1561,13 +1532,6 @@ export interface components { * } * } */ config: components['schemas']['AudioResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** AudioResourceRead */ AudioResourceRead: { @@ -2061,10 +2025,11 @@ export interface components { */ resourceId: string; /** - * Position - * @description Position of the content this correction refers to + * Locationid + * @description ID of the location this correction refers to + * @example 5eb7cf5a86d9755df3a6c593 */ - position: number; + locationId: string; /** * Note * @description Content of the correction note @@ -2084,6 +2049,17 @@ export interface components { * @example 5eb7cf5a86d9755df3a6c593 */ resourceId: string; + /** + * Locationid + * @description ID of the location this correction refers to + * @example 5eb7cf5a86d9755df3a6c593 + */ + locationId: string; + /** + * Note + * @description Content of the correction note + */ + note: string; /** * Userid * @description ID of the user who created the correction note @@ -2092,14 +2068,9 @@ export interface components { userId: string; /** * Position - * @description Position of the content this correction refers to + * @description Position of the correction on the resource's level */ position: number; - /** - * Note - * @description Content of the correction note - */ - note: string; /** * Date * Format: date-time @@ -2355,35 +2326,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -2414,13 +2356,6 @@ export interface components { * } * } */ config: components['schemas']['ExternalReferencesResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** ExternalReferencesResourceRead */ ExternalReferencesResourceRead: { @@ -2894,35 +2829,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -2953,13 +2859,6 @@ export interface components { * } * } */ config: components['schemas']['ImagesResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** ImagesResourceRead */ ImagesResourceRead: { @@ -3187,10 +3086,10 @@ export interface components { LocaleKey: 'deDE' | 'enUS'; /** LocationCoverage */ LocationCoverage: { + /** Locid */ + locId: string; /** Label */ label: string; - /** Position */ - position: number; /** * Covered * @default false @@ -3235,11 +3134,23 @@ export interface components { LocationData: { /** * Locationpath + * @description Path of locations from level 0 to this location * @default [] */ locationPath: components['schemas']['LocationRead'][]; + /** + * Prev + * @description ID of the preceding location on the same level + */ + prev?: string | null; + /** + * Next + * @description ID of the subsequent location on the same level + */ + next?: string | null; /** * Contents + * @description Contents of various resources on this location * @default [] */ contents: ( @@ -3541,35 +3452,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -3611,13 +3493,6 @@ export interface components { * } * } */ config: components['schemas']['PlainTextResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** PlainTextResourceRead */ PlainTextResourceRead: { @@ -4430,35 +4305,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -4489,13 +4335,6 @@ export interface components { * } * } */ config: components['schemas']['RichTextResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** RichTextResourceRead */ RichTextResourceRead: { @@ -5004,35 +4843,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -5065,13 +4875,6 @@ export interface components { * "multiValueDelimiter": "/" * } */ config: components['schemas']['TextAnnotationResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** TextAnnotationResourceRead */ TextAnnotationResourceRead: { @@ -6006,14 +5809,16 @@ export interface operations { }; getLocationData: { parameters: { - query: { - /** @description ID of text to look up data for */ - txt: string; - /** @description Location level */ - lvl: number; - /** @description Location position */ - pos: number; - /** @description ID (or list of IDs) of resource(s) to return content data for */ + query?: { + /** @description ID of location to request data for */ + id?: string | null; + /** @description ID of text the target location belongs to (needed if no location ID is given) */ + txt?: string | null; + /** @description Location level (only used if no location ID is given, text's default level is used by default) */ + lvl?: number | null; + /** @description Location position (only used if no location ID is given) */ + pos?: number; + /** @description ID (or list of IDs) of resource(s) to return contents for */ res?: string[]; /** @description Only return contents for the head location of the path */ head?: boolean; @@ -6035,6 +5840,15 @@ export interface operations { 'application/json': components['schemas']['LocationData']; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; /** @description Validation Error */ 422: { headers: { @@ -6046,15 +5860,15 @@ export interface operations { }; }; }; - getNearestContentPosition: { + getNearestContentLocationId: { parameters: { query: { - /** @description Location position */ - pos: number; + /** @description Current content location */ + loc: string; /** @description ID of resource to return nearest location with content for */ res: string; - /** @description Whether to look for the nearest preceding or subsequent location with content */ - mode?: 'preceding' | 'subsequent'; + /** @description Whether to look for the nearest preceding (before) or subsequent (after) location with content */ + dir?: 'before' | 'after'; }; header?: never; path?: never; @@ -6068,7 +5882,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': number; + 'application/json': string; }; }; /** @description Not Found */ @@ -6448,6 +6262,15 @@ export interface operations { 'application/json': components['schemas']['CorrectionRead']; }; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; /** @description Not Found */ 404: { headers: { diff --git a/Tekst-Web/src/components/browse/BookmarksWidget.vue b/Tekst-Web/src/components/browse/BookmarksWidget.vue index ab5572e36..7eaa6c186 100644 --- a/Tekst-Web/src/components/browse/BookmarksWidget.vue +++ b/Tekst-Web/src/components/browse/BookmarksWidget.vue @@ -73,8 +73,10 @@ async function handleBookmarkSelect(bookmark: BookmarkRead) { showModal.value = false; router.replace({ name: 'browse', - params: { text: pfData.value?.texts.find((t) => t.id === bookmark.textId)?.slug || '' }, - query: { lvl: bookmark.level, pos: bookmark.position }, + params: { + textSlug: pfData.value?.texts.find((t) => t.id === bookmark.textId)?.slug || '', + locId: bookmark.locationId, + }, }); } diff --git a/Tekst-Web/src/components/browse/BrowseLocationControls.vue b/Tekst-Web/src/components/browse/BrowseLocationControls.vue index 9adcee503..dc8325f42 100644 --- a/Tekst-Web/src/components/browse/BrowseLocationControls.vue +++ b/Tekst-Web/src/components/browse/BrowseLocationControls.vue @@ -32,12 +32,13 @@ const { ArrowLeft, ArrowRight } = useMagicKeys(); const showLocationSelectModal = ref(false); function gotoPosition(direction: 'prev' | 'next') { - const targetPos = browse.position + (direction === 'prev' ? -1 : 1); + const targetLocId = direction === 'prev' ? browse.prevLocationId : browse.nextLocationId; + if (!targetLocId) return; router.replace({ - ...route, - query: { - ...route.query, - pos: targetPos >= 0 ? targetPos : 0, + name: 'browse', + params: { + ...route.params, + locId: targetLocId, }, }); emit('navigate'); @@ -50,8 +51,7 @@ function handleLocationSelect(locationPath: LocationRead[]) { name: 'browse', params: { ...route.params }, query: { - lvl: selectedLocation.level, - pos: selectedLocation.position, + loc: selectedLocation.id, }, }); emit('navigate'); @@ -71,11 +71,11 @@ whenever(ArrowRight, () => {