diff --git a/Tekst-API/.env.test b/Tekst-API/.env.test index 6b93c7ee..17ccafd1 100644 --- a/Tekst-API/.env.test +++ b/Tekst-API/.env.test @@ -13,5 +13,5 @@ TEKST_DB__NAME=tekst_testing TEKST_API_DOC__TITLE=Tekst-TESTING # security -TEKST_SECURITY__INIT_ADMIN_EMAIL=test-admin@test.com +TEKST_SECURITY__INIT_ADMIN_EMAIL=test-admin@tekst.dev TEKST_SECURITY__INIT_ADMIN_PASSWORD=testTEST123 diff --git a/Tekst-API/openapi.json b/Tekst-API/openapi.json index 171d806f..2ed9b949 100644 --- a/Tekst-API/openapi.json +++ b/Tekst-API/openapi.json @@ -1282,6 +1282,197 @@ } } }, + "/corrections": { + "post": { + "tags": [ + "corrections" + ], + "summary": "Create correction", + "description": "Creates a correction note referring to a specific content", + "operationId": "createCorrection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CorrectionCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CorrectionRead" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "APIKeyCookie": [] + }, + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/corrections/{resourceId}": { + "get": { + "tags": [ + "corrections" + ], + "summary": "Get corrections", + "description": "Returns a list of all corrections for a specific resource", + "operationId": "getCorrections", + "security": [ + { + "APIKeyCookie": [] + }, + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "resourceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593", + "title": "Resourceid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CorrectionRead" + }, + "title": "Response Get Corrections Corrections Resourceid Get" + } + } + } + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + }, + "description": "Not Found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/corrections/{id}": { + "delete": { + "tags": [ + "corrections" + ], + "summary": "Delete correction", + "description": "Deletes a specific correction note", + "operationId": "deleteCorrection", + "security": [ + { + "APIKeyCookie": [] + }, + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593", + "title": "Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + }, + "description": "Not Found" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + }, + "description": "Forbidden" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/locations": { "post": { "tags": [ @@ -6539,9 +6730,9 @@ "AdminNotificationTrigger": { "type": "string", "enum": [ - "userAwaitsActivation" - ], - "const": "userAwaitsActivation" + "userAwaitsActivation", + "newCorrection" + ] }, "AdvancedSearchRequestBody": { "properties": { @@ -8063,6 +8254,94 @@ ], "title": "CommonResourceSearchQueryData" }, + "CorrectionCreate": { + "properties": { + "resourceId": { + "type": "string", + "title": "Resourceid", + "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" + }, + "note": { + "type": "string", + "maxLength": 1000, + "minLength": 1, + "title": "Note", + "description": "Content of the correction note" + } + }, + "type": "object", + "required": [ + "resourceId", + "position", + "note" + ], + "title": "CorrectionCreate" + }, + "CorrectionRead": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "example": "5eb7cf5a86d9755df3a6c593" + }, + "resourceId": { + "type": "string", + "title": "Resourceid", + "description": "ID of the resource this correction refers to", + "example": "5eb7cf5a86d9755df3a6c593" + }, + "userId": { + "type": "string", + "title": "Userid", + "description": "ID of the user who created the correction note", + "example": "5eb7cf5a86d9755df3a6c593" + }, + "position": { + "type": "integer", + "title": "Position", + "description": "Position of the content this correction refers to" + }, + "note": { + "type": "string", + "maxLength": 1000, + "minLength": 1, + "title": "Note", + "description": "Content of the correction note" + }, + "date": { + "type": "string", + "format": "date-time", + "title": "Date", + "description": "Date when the correction was created" + }, + "locationLabels": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Locationlabels", + "description": "Text location labels from root to target location" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "id", + "resourceId", + "userId", + "position", + "note", + "date", + "locationLabels" + ], + "title": "CorrectionRead" + }, "DeepLLinksConfig": { "properties": { "enabled": { @@ -14868,11 +15147,12 @@ "$ref": "#/components/schemas/UserNotificationTrigger" }, "type": "array", - "maxItems": 3, + "maxItems": 4, "title": "Usernotificationtriggers", "description": "Events that trigger notifications for this user", "default": [ "messageReceived", + "newCorrection", "resourceProposed", "resourcePublished" ] @@ -14882,11 +15162,12 @@ "$ref": "#/components/schemas/AdminNotificationTrigger" }, "type": "array", - "maxItems": 1, + "maxItems": 2, "title": "Adminnotificationtriggers", "description": "Events that trigger admin notifications for this user", "default": [ - "userAwaitsActivation" + "userAwaitsActivation", + "newCorrection" ] }, "seen": { @@ -15099,6 +15380,7 @@ "type": "string", "enum": [ "messageReceived", + "newCorrection", "resourceProposed", "resourcePublished" ] @@ -15193,11 +15475,12 @@ "$ref": "#/components/schemas/UserNotificationTrigger" }, "type": "array", - "maxItems": 3, + "maxItems": 4, "title": "Usernotificationtriggers", "description": "Events that trigger notifications for this user", "default": [ "messageReceived", + "newCorrection", "resourceProposed", "resourcePublished" ] @@ -15207,11 +15490,12 @@ "$ref": "#/components/schemas/AdminNotificationTrigger" }, "type": "array", - "maxItems": 1, + "maxItems": 2, "title": "Adminnotificationtriggers", "description": "Events that trigger admin notifications for this user", "default": [ - "userAwaitsActivation" + "userAwaitsActivation", + "newCorrection" ] }, "seen": { @@ -15468,11 +15752,12 @@ "$ref": "#/components/schemas/UserNotificationTrigger" }, "type": "array", - "maxItems": 3, + "maxItems": 4, "title": "Usernotificationtriggers", "description": "Events that trigger notifications for this user", "default": [ "messageReceived", + "newCorrection", "resourceProposed", "resourcePublished" ] @@ -15482,11 +15767,12 @@ "$ref": "#/components/schemas/AdminNotificationTrigger" }, "type": "array", - "maxItems": 1, + "maxItems": 2, "title": "Adminnotificationtriggers", "description": "Events that trigger admin notifications for this user", "default": [ - "userAwaitsActivation" + "userAwaitsActivation", + "newCorrection" ] }, "seen": { @@ -15609,6 +15895,14 @@ "url": "https://vedawebproject.github.io/Tekst" } }, + { + "name": "corrections", + "description": "Correction notes from user to users", + "externalDocs": { + "description": "View full documentation", + "url": "https://vedawebproject.github.io/Tekst" + } + }, { "name": "search", "description": "Search operations and search index maintenance", diff --git a/Tekst-API/tekst/db.py b/Tekst-API/tekst/db.py index ed981cdb..9040636a 100644 --- a/Tekst-API/tekst/db.py +++ b/Tekst-API/tekst/db.py @@ -7,6 +7,7 @@ from tekst.logs import log from tekst.models.bookmark import BookmarkDocument from tekst.models.content import ContentBaseDocument +from tekst.models.correction import CorrectionDocument from tekst.models.location import LocationDocument from tekst.models.message import UserMessageDocument from tekst.models.resource import ResourceBaseDocument @@ -52,6 +53,7 @@ async def init_odm(db: Database = get_db()) -> None: LocationDocument, ResourceBaseDocument, ContentBaseDocument, + CorrectionDocument, PlatformSettingsDocument, ClientSegmentDocument, UserDocument, diff --git a/Tekst-API/tekst/models/correction.py b/Tekst-API/tekst/models/correction.py new file mode 100644 index 00000000..477a6cfe --- /dev/null +++ b/Tekst-API/tekst/models/correction.py @@ -0,0 +1,95 @@ +from datetime import datetime +from typing import Annotated + +from pydantic import Field, StringConstraints + +from tekst.models.common import ( + DocumentBase, + ModelBase, + ModelFactoryMixin, + PydanticObjectId, +) +from tekst.utils import validators as val + + +class Correction(ModelBase, ModelFactoryMixin): + resource_id: Annotated[ + PydanticObjectId, + Field( + description="ID of the resource this correction refers to", + ), + ] + user_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", + ), + ] + note: Annotated[ + str, + Field( + description="Content of the correction note", + ), + StringConstraints( + min_length=1, + max_length=1000, + strip_whitespace=True, + ), + 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[ + PydanticObjectId, + Field( + description="ID of the resource this correction refers to", + ), + ] + position: Annotated[ + int, + Field( + description="Position of the content this correction refers to", + ), + ] + note: Annotated[ + str, + Field( + description="Content of the correction note", + ), + StringConstraints( + min_length=1, + max_length=1000, + strip_whitespace=True, + ), + val.CleanupMultiline, + ] + + +class CorrectionDocument(Correction, DocumentBase): + class Settings(DocumentBase.Settings): + name = "corrections" + indexes = [ + "content_id", + ] + + +CorrectionRead = Correction.read_model() diff --git a/Tekst-API/tekst/models/notifications.py b/Tekst-API/tekst/models/notifications.py index a3b437f4..9af4ca39 100644 --- a/Tekst-API/tekst/models/notifications.py +++ b/Tekst-API/tekst/models/notifications.py @@ -14,5 +14,6 @@ class TemplateIdentifier(Enum): EMAIL_SUPERUSER_SET = "superuserSet" EMAIL_SUPERUSER_UNSET = "superuserUnset" EMAIL_MESSAGE_RECEIVED = "messageReceived" + EMAIL_NEW_CORRECTION = "newCorrection" USRMSG_RESOURCE_PROPOSED = "resourceProposed" USRMSG_RESOURCE_PUBLISHED = "resourcePublished" diff --git a/Tekst-API/tekst/models/user.py b/Tekst-API/tekst/models/user.py index 85d76b74..30ab20d8 100644 --- a/Tekst-API/tekst/models/user.py +++ b/Tekst-API/tekst/models/user.py @@ -41,6 +41,7 @@ "UserNotificationTrigger", Literal[ TemplateIdentifier.EMAIL_MESSAGE_RECEIVED.value, + TemplateIdentifier.EMAIL_NEW_CORRECTION.value, TemplateIdentifier.USRMSG_RESOURCE_PROPOSED.value, TemplateIdentifier.USRMSG_RESOURCE_PUBLISHED.value, ], @@ -55,7 +56,10 @@ AdminNotificationTrigger = TypeAliasType( "AdminNotificationTrigger", - Literal[TemplateIdentifier.EMAIL_USER_AWAITS_ACTIVATION.value], + Literal[ + TemplateIdentifier.EMAIL_USER_AWAITS_ACTIVATION.value, + TemplateIdentifier.EMAIL_NEW_CORRECTION.value, + ], ) AdminNotificationTriggers = Annotated[ list[AdminNotificationTrigger], diff --git a/Tekst-API/tekst/notifications/__init__.py b/Tekst-API/tekst/notifications/__init__.py index 3fd3a54d..9aaa072c 100644 --- a/Tekst-API/tekst/notifications/__init__.py +++ b/Tekst-API/tekst/notifications/__init__.py @@ -89,6 +89,8 @@ async def send_notification( template_id: TemplateIdentifier, **kwargs, ): + if not to_user or not template_id: # pragma: no cover + raise ValueError("Missing user or template ID.") templates = _get_notification_templates(template_id, to_user.locale or "enUS") msg_parts = dict() settings = await get_settings() diff --git a/Tekst-API/tekst/notifications/templates/deDE/new_correction.html b/Tekst-API/tekst/notifications/templates/deDE/new_correction.html new file mode 100644 index 00000000..9a40b4cf --- /dev/null +++ b/Tekst-API/tekst/notifications/templates/deDE/new_correction.html @@ -0,0 +1,15 @@ +Hallo, {to_user_name}!
+
+{from_user_name} hat eine neue Korrekturnotiz zur Ressource "{resource_title}" +erstellt:
+
+-----------------------------
+{correction_note}
+-----------------------------
+
+Klicken Sie +hier, um alle Korrekturnotizen zu "{resource_title}" einzusehen und zu +verwalten.
+
+Bis bald auf {platform_name}! diff --git a/Tekst-API/tekst/notifications/templates/deDE/new_correction.subject b/Tekst-API/tekst/notifications/templates/deDE/new_correction.subject new file mode 100644 index 00000000..ed8bb773 --- /dev/null +++ b/Tekst-API/tekst/notifications/templates/deDE/new_correction.subject @@ -0,0 +1 @@ +Neue Korrekturnotiz zu "{resource_title}" von {from_user_name} diff --git a/Tekst-API/tekst/notifications/templates/deDE/new_correction.txt b/Tekst-API/tekst/notifications/templates/deDE/new_correction.txt new file mode 100644 index 00000000..a37c2c51 --- /dev/null +++ b/Tekst-API/tekst/notifications/templates/deDE/new_correction.txt @@ -0,0 +1,12 @@ +Hallo, {to_user_name}! + +{from_user_name} hat eine neue Korrekturnotiz zur Ressource "{resource_title}" erstellt: + +----------------------------- +{correction_note} +----------------------------- + +Klicken Sie auf folgenden Link, um alle Korrekturnotizen zu "{resource_title}" einzusehen und zu verwalten: +{web_url}/text/{text_slug}/resources/{resource_id}/corrections + +Bis bald auf {platform_name}! diff --git a/Tekst-API/tekst/notifications/templates/enUS/new_correction.html b/Tekst-API/tekst/notifications/templates/enUS/new_correction.html new file mode 100644 index 00000000..11260777 --- /dev/null +++ b/Tekst-API/tekst/notifications/templates/enUS/new_correction.html @@ -0,0 +1,15 @@ +Dear {to_user_name},
+
+{from_user_name} created a new correction note on "{resource_title}":
+
+-----------------------------
+{correction_note}
+-----------------------------
+
+Click +here +to read and manage all correction notes on "{resource_title}".
+
+See you on {platform_name}! diff --git a/Tekst-API/tekst/notifications/templates/enUS/new_correction.subject b/Tekst-API/tekst/notifications/templates/enUS/new_correction.subject new file mode 100644 index 00000000..548f5392 --- /dev/null +++ b/Tekst-API/tekst/notifications/templates/enUS/new_correction.subject @@ -0,0 +1 @@ +New correction note on "{resource_title}" by {from_user_name} diff --git a/Tekst-API/tekst/notifications/templates/enUS/new_correction.txt b/Tekst-API/tekst/notifications/templates/enUS/new_correction.txt new file mode 100644 index 00000000..a543bb32 --- /dev/null +++ b/Tekst-API/tekst/notifications/templates/enUS/new_correction.txt @@ -0,0 +1,12 @@ +Dear {to_user_name}, + +{from_user_name} created a new correction note on "{resource_title}": + +----------------------------- +{correction_note} +----------------------------- + +Visit this URL to read and manage all correction notes on "{resource_title}": +{web_url}/text/{text_slug}/resources/{resource_id}/corrections + +See you on {platform_name}! diff --git a/Tekst-API/tekst/openapi/tags_metadata.py b/Tekst-API/tekst/openapi/tags_metadata.py index 983c78a4..631604d3 100644 --- a/Tekst-API/tekst/openapi/tags_metadata.py +++ b/Tekst-API/tekst/openapi/tags_metadata.py @@ -35,6 +35,14 @@ def get_tags_metadata(documentation_url: str) -> list[dict[str, Any]]: "url": documentation_url, }, }, + { + "name": "corrections", + "description": "Correction notes from user to users", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, { "name": "search", "description": "Search operations and search index maintenance", diff --git a/Tekst-API/tekst/routers/corrections.py b/Tekst-API/tekst/routers/corrections.py new file mode 100644 index 00000000..d7de078e --- /dev/null +++ b/Tekst-API/tekst/routers/corrections.py @@ -0,0 +1,157 @@ +from datetime import datetime +from typing import Annotated + +from beanie import PydanticObjectId +from fastapi import APIRouter, Path, status + +from tekst import errors +from tekst.auth import UserDep +from tekst.models.correction import CorrectionCreate, CorrectionDocument, CorrectionRead +from tekst.models.location import LocationDocument +from tekst.models.notifications import TemplateIdentifier +from tekst.models.resource import ResourceBaseDocument +from tekst.models.text import TextDocument +from tekst.models.user import UserDocument +from tekst.notifications import broadcast_admin_notification, send_notification +from tekst.utils import pick_translation + + +# initialize corrections router +router = APIRouter( + prefix="/corrections", + tags=["corrections"], +) + + +@router.post( + "", + response_model=CorrectionRead, + status_code=status.HTTP_201_CREATED, + responses=errors.responses( + [ + errors.E_404_RESOURCE_NOT_FOUND, + errors.E_404_CONTENT_NOT_FOUND, + ] + ), +) +async def create_correction( + correction: CorrectionCreate, + user: UserDep, +) -> CorrectionDocument: + """Creates a correction note referring to a specific content""" + + # check if the resource this content belongs to is readable by user + resource_doc = await ResourceBaseDocument.find_one( + ResourceBaseDocument.id == correction.resource_id, + await ResourceBaseDocument.access_conditions_read(user), + with_children=True, + ) + if not resource_doc: + 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, + ) + if not location_doc: + raise errors.E_404_CONTENT_NOT_FOUND + + # construct full label + location_labels = [location_doc.label] + parent_location_id = location_doc.parent_id + while parent_location_id: + parent_location = await LocationDocument.get(parent_location_id) + location_labels.insert(0, parent_location.label) + parent_location_id = parent_location.parent_id + + # notify the resource's owner (or admins if it's public) of the new correction + msg_specific_attrs = { + "from_user_name": user.name if "name" in user.public_fields else user.username, + "correction_note": correction.note, + "text_slug": (await TextDocument.get(resource_doc.text_id)).slug, + "resource_id": resource_doc.id, + "resource_title": pick_translation(resource_doc.title), + } + if not resource_doc.public and resource_doc.owner_id: + to_user: UserDocument = await UserDocument.get(resource_doc.owner_id) + if ( + to_user + and to_user.id != user.id + and TemplateIdentifier.EMAIL_NEW_CORRECTION.value + in to_user.user_notification_triggers + ): + await send_notification( + to_user, + TemplateIdentifier.EMAIL_NEW_CORRECTION, + **msg_specific_attrs, + ) + else: + await broadcast_admin_notification( + TemplateIdentifier.EMAIL_NEW_CORRECTION, + **msg_specific_attrs, + ) + + # create correction + return await CorrectionDocument( + resource_id=correction.resource_id, + user_id=user.id, + position=correction.position, + note=correction.note, + date=datetime.utcnow(), + location_labels=location_labels, + ).create() + + +@router.get( + "/{resourceId}", + response_model=list[CorrectionRead], + status_code=status.HTTP_200_OK, + responses=errors.responses( + [ + errors.E_404_RESOURCE_NOT_FOUND, + ] + ), +) +async def get_corrections( + resource_id: Annotated[PydanticObjectId, Path(alias="resourceId")], + user: UserDep, +) -> list[CorrectionDocument]: + """Returns a list of all corrections for a specific resource""" + # check if the requested resource is owned by this user + resource_doc = await ResourceBaseDocument.get( + resource_id, + with_children=True, + ) + if not resource_doc or (user.id != resource_doc.owner_id and not user.is_superuser): + raise errors.E_404_RESOURCE_NOT_FOUND + # return all corrections for the resource + return await CorrectionDocument.find( + CorrectionDocument.resource_id == resource_id, + ).to_list() + + +@router.delete( + "/{id}", + status_code=status.HTTP_204_NO_CONTENT, + responses=errors.responses([errors.E_404_NOT_FOUND, errors.E_403_FORBIDDEN]), +) +async def delete_correction( + correction_id: Annotated[PydanticObjectId, Path(alias="id")], + user: UserDep, +) -> None: + """Deletes a specific correction note""" + # get correction + correction_doc = await CorrectionDocument.get(correction_id) + if not correction_doc: + raise errors.E_404_NOT_FOUND + # check if the requested resource is owned by this user + resource_doc = await ResourceBaseDocument.get( + correction_doc.resource_id, + with_children=True, + ) + if not resource_doc or (user.id != resource_doc.owner_id and not user.is_superuser): + raise errors.E_403_FORBIDDEN + # delete correction + await correction_doc.delete() diff --git a/Tekst-API/tekst/routers/messages.py b/Tekst-API/tekst/routers/messages.py index 4e1ee914..82fff1ff 100644 --- a/Tekst-API/tekst/routers/messages.py +++ b/Tekst-API/tekst/routers/messages.py @@ -16,8 +16,9 @@ UserMessageRead, UserMessageThread, ) +from tekst.models.notifications import TemplateIdentifier from tekst.models.user import UserDocument, UserRead, UserReadPublic -from tekst.notifications import TemplateIdentifier, send_notification +from tekst.notifications import send_notification from tekst.settings import get_settings diff --git a/Tekst-API/tekst/routers/resources.py b/Tekst-API/tekst/routers/resources.py index d4a6ef62..e859bbea 100644 --- a/Tekst-API/tekst/routers/resources.py +++ b/Tekst-API/tekst/routers/resources.py @@ -28,6 +28,7 @@ from tekst.config import ConfigDep, TekstConfig from tekst.logs import log from tekst.models.content import ContentBaseDocument +from tekst.models.correction import CorrectionDocument from tekst.models.exchange import ResourceImportData from tekst.models.location import LocationDocument from tekst.models.resource import ( @@ -91,6 +92,15 @@ async def preprocess_resource_read( resource.shared_write_users = await UserDocument.find( In(UserDocument.id, resource.shared_write) ).to_list() + # include corrections count if user is owner of the resource + # or, if resource is public, user is superuser + if for_user and ( + (not resource.public and for_user.id == resource.owner_id) + or (resource.public and for_user.is_superuser) + ): + resource.corrections = await CorrectionDocument.find( + CorrectionDocument.resource_id == resource.id + ).count() return resource diff --git a/Tekst-API/tekst/sample_data/db/users.json b/Tekst-API/tekst/sample_data/db/users.json index 6a3a5e0c..5f476714 100644 --- a/Tekst-API/tekst/sample_data/db/users.json +++ b/Tekst-API/tekst/sample_data/db/users.json @@ -1,8 +1,8 @@ [ { "_id": { "$oid": "65c5fe0b691066aabd498236" }, - "email": "inactive@test.com", - "hashed_password": "$2b$12$rlOcQAkvifr8kwsdDo.AruQWyZ5W5C1a1lUe0xZ55/Q0xwM2aCgZO", + "email": "inactive@tekst.dev", + "hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$01Q6hvaoSh5Eb95bH/yhMA$/JajttLvD4V+iSWF7pEO5j0+9ygb+TUjo+sG4gcEdrU", "is_active": false, "is_superuser": false, "is_verified": true, @@ -14,17 +14,19 @@ "bio": "Beavers (genus Castor) are large, semiaquatic rodents of the Northern Hemisphere. There are two existing species: the North American beaver (Castor canadensis) and the Eurasian beaver (C. fiber). Beavers are the second-largest living rodents, after capybaras, weighing up to 50 kg (110 lb). They have stout bodies with large heads, long chisel-like incisors, brown or gray fur, hand-like front feet, webbed back feet, and tails that are flat and scaly. The two species differ in skull and tail shape and fur color. Beavers can be found in a number of freshwater habitats, such as rivers, streams, lakes and ponds. They are herbivorous, consuming tree bark, aquatic plants, grasses and sedges. (Wikipedia)", "public_fields": ["name", "affiliation", "bio"], "created_at": { "$date": "2024-02-09T10:27:22.844Z" }, - "admin_notification_triggers": ["userAwaitsActivation"], + "admin_notification_triggers": ["userAwaitsActivation", "newCorrection"], "user_notification_triggers": [ "messageReceived", "resourceProposed", - "resourcePublished" - ] + "resourcePublished", + "newCorrection" + ], + "seen": null }, { "_id": { "$oid": "65c5fe0c691066aabd498237" }, - "email": "unverified@test.com", - "hashed_password": "$2b$12$WDC.pgdmCfJp3OmhzQAXwumg6CGJMboFOy0aBHYgCKmWJBGtadEb.", + "email": "unverified@tekst.dev", + "hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$ZTV5euXVw8R16qMbCFbJ1A$8jVUgewY9AuknO2lWLfZzkl9dE3wn3/Kp3Ft5ccox+M", "is_active": true, "is_superuser": false, "is_verified": false, @@ -36,39 +38,43 @@ "bio": "Zebras (subgenus Hippotigris) are African equines with distinctive black-and-white striped coats. There are three living species: Grévy's zebra (Equus grevyi), the plains zebra (E. quagga), and the mountain zebra (E. zebra). Zebras share the genus Equus with horses and asses, the three groups being the only living members of the family Equidae. Zebra stripes come in different patterns, unique to each individual. Several theories have been proposed for the function of these stripes, with most evidence supporting them as a deterrent for biting flies. Zebras inhabit eastern and southern Africa and can be found in a variety of habitats such as savannahs, grasslands, woodlands, shrublands, and mountainous areas. (Wikipedia)", "public_fields": ["name", "affiliation", "bio"], "created_at": { "$date": "2024-02-09T10:27:22.844Z" }, - "admin_notification_triggers": ["userAwaitsActivation"], + "admin_notification_triggers": ["userAwaitsActivation", "newCorrection"], "user_notification_triggers": [ "messageReceived", "resourceProposed", - "resourcePublished" - ] + "resourcePublished", + "newCorrection" + ], + "seen": null }, { "_id": { "$oid": "65c5fe0c691066aabd498238" }, - "email": "user@test.com", - "hashed_password": "$2b$12$m1wWIJ3F5dqTjS.e/aml.ep08Cv.T21gmjQJ91usbzyF6DHLkSILC", + "email": "user@tekst.dev", + "hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$eMAYBl0Pdc4I5iW7U4P9pw$W0YMu4EGJriJeYk1IMZ5Nzesr4gi60srmNYUwRVt9qE", "is_active": true, "is_superuser": false, "is_verified": true, "username": "regular_starling", "name": "Regular Starling", "affiliation": "Cherrypicker's University", - "locale": null, + "locale": "enUS", "avatar_url": "https://upload.wikimedia.org/wikipedia/commons/3/32/Star_Sturnus_vulgaris.jpg", "bio": "Starlings are small to medium-sized passerine birds in the family Sturnidae. The Sturnidae are named for the genus Sturnus, which in turn comes from the Latin word for starling, sturnus. The family contains 128 species which are divided into 36 genera. Many Asian species, particularly the larger ones, are called mynas, and many African species are known as glossy starlings because of their iridescent plumage. Starlings are native to Europe, Asia, and Africa, as well as northern Australia and the islands of the tropical Pacific. Several European and Asian species have been introduced to these areas, as well as North America, Hawaii, and New Zealand, where they generally compete for habitats with native birds and are considered to be invasive species. The starling species familiar to most people in Europe and North America is the common starling, and throughout much of Asia and the Pacific, the common myna is indeed common. (Wikipedia)", "public_fields": ["name", "affiliation", "bio"], - "created_at": { "$date": "2024-02-09T10:27:22.844Z" }, - "admin_notification_triggers": ["userAwaitsActivation"], "user_notification_triggers": [ "messageReceived", "resourceProposed", - "resourcePublished" - ] + "resourcePublished", + "newCorrection" + ], + "admin_notification_triggers": ["userAwaitsActivation", "newCorrection"], + "seen": false, + "created_at": { "$date": "2024-02-09T10:27:22.844Z" } }, { "_id": { "$oid": "65c5fe0c691066aabd498239" }, - "email": "superuser@test.com", - "hashed_password": "$2b$12$sty8opvomxhW3w0rNEZ/DO7ZXVR3v62shj4XZaT362EolDkmkC7UK", + "email": "superuser@tekst.dev", + "hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$4Xc7TPGlz2GY8BMsHW9CUA$ahxtdaldT74kp2dIT2BL92oktatSm8f76wOolg6+nqM", "is_active": true, "is_superuser": true, "is_verified": true, @@ -79,12 +85,14 @@ "avatar_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Peacock.displaying.better.800pix.jpg/301px-Peacock.displaying.better.800pix.jpg", "bio": "Peafowl is a common name for two bird species in the genera Pavo and Afropavo within the tribe Pavonini of the family Phasianidae (the pheasants and their allies). Male peafowl are referred to as peacocks, and female peafowl are referred to as peahens, although peafowl of either sex are often referred to colloquially as \"peacocks\". (Wikipedia)", "public_fields": ["name", "affiliation", "bio"], - "created_at": { "$date": "2024-02-09T10:27:22.844Z" }, - "admin_notification_triggers": ["userAwaitsActivation"], "user_notification_triggers": [ - "messageReceived", "resourceProposed", - "resourcePublished" - ] + "resourcePublished", + "messageReceived", + "newCorrection" + ], + "admin_notification_triggers": ["userAwaitsActivation", "newCorrection"], + "seen": true, + "created_at": { "$date": "2024-02-09T10:27:22.844Z" } } ] diff --git a/Tekst-API/tests/test_api_auth.py b/Tekst-API/tests/test_api_auth.py index da3019da..71f5e999 100644 --- a/Tekst-API/tests/test_api_auth.py +++ b/Tekst-API/tests/test_api_auth.py @@ -70,7 +70,7 @@ async def test_register_email_exists( ): payload = get_fake_user() - payload["email"] = "first@test.com" + payload["email"] = "first@tekst.dev" payload["username"] = "first" resp = await test_client.post("/auth/register", json=payload) assert resp.status_code == 201, status_fail_msg(201, resp) diff --git a/Tekst-API/tests/test_email.py b/Tekst-API/tests/test_email.py index aab08474..5f3d19cd 100644 --- a/Tekst-API/tests/test_email.py +++ b/Tekst-API/tests/test_email.py @@ -12,7 +12,7 @@ async def test_sending_email(): UserRead( id="645b469846c001259ec09d63", username="testuser", - email="testuser@test.com", + email="testuser@tekst.dev", name="Foo Bar", affiliation="Baz", is_active=True, diff --git a/Tekst-Web/src/api/index.ts b/Tekst-Web/src/api/index.ts index 0925404d..fa7325cc 100644 --- a/Tekst-Web/src/api/index.ts +++ b/Tekst-Web/src/api/index.ts @@ -148,6 +148,11 @@ export type ResourceExportFormat = NonNullable< export type BookmarkRead = components['schemas']['BookmarkRead']; export type BookmarkCreate = components['schemas']['BookmarkCreate']; +// correction + +export type CorrectionRead = components['schemas']['CorrectionRead']; +export type CorrectionCreate = components['schemas']['CorrectionCreate']; + // user export type UserCreate = components['schemas']['UserCreate']; @@ -197,6 +202,7 @@ export type ResourceType = type ResourceReadExtras = { active?: boolean; coverage?: ResourceCoverage; + corrections?: number; }; export type PlainTextContentRead = components['schemas']['PlainTextContentRead']; diff --git a/Tekst-Web/src/api/schema.d.ts b/Tekst-Web/src/api/schema.d.ts index 72d32ac1..d2ad1f64 100644 --- a/Tekst-Web/src/api/schema.d.ts +++ b/Tekst-Web/src/api/schema.d.ts @@ -99,6 +99,27 @@ export interface paths { /** Update content */ patch: operations['updateContent']; }; + '/corrections': { + /** + * Create correction + * @description Creates a correction note referring to a specific content + */ + post: operations['createCorrection']; + }; + '/corrections/{resourceId}': { + /** + * Get corrections + * @description Returns a list of all corrections for a specific resource + */ + get: operations['getCorrections']; + }; + '/corrections/{id}': { + /** + * Delete correction + * @description Deletes a specific correction note + */ + delete: operations['deleteCorrection']; + }; '/locations': { /** Find locations */ get: operations['findLocations']; @@ -392,11 +413,8 @@ export type webhooks = Record; export interface components { schemas: { - /** - * @constant - * @enum {string} - */ - AdminNotificationTrigger: 'userAwaitsActivation'; + /** @enum {string} */ + AdminNotificationTrigger: 'userAwaitsActivation' | 'newCorrection'; /** AdvancedSearchRequestBody */ AdvancedSearchRequestBody: { /** @@ -1171,6 +1189,67 @@ export interface components { */ cmt?: string; }; + /** CorrectionCreate */ + CorrectionCreate: { + /** + * Resourceid + * @description ID of the resource this correction refers to + * @example 5eb7cf5a86d9755df3a6c593 + */ + resourceId: string; + /** + * Position + * @description Position of the content this correction refers to + */ + position: number; + /** + * Note + * @description Content of the correction note + */ + note: string; + }; + /** CorrectionRead */ + CorrectionRead: { + /** + * Id + * @example 5eb7cf5a86d9755df3a6c593 + */ + id: string; + /** + * Resourceid + * @description ID of the resource this correction refers to + * @example 5eb7cf5a86d9755df3a6c593 + */ + resourceId: string; + /** + * Userid + * @description ID of the user who created the correction note + * @example 5eb7cf5a86d9755df3a6c593 + */ + userId: string; + /** + * Position + * @description Position of the content this correction refers to + */ + position: number; + /** + * Note + * @description Content of the correction note + */ + note: string; + /** + * Date + * Format: date-time + * @description Date when the correction was created + */ + date: string; + /** + * Locationlabels + * @description Text location labels from root to target location + */ + locationLabels: string[]; + [key: string]: unknown; + }; /** * DeepLLinksConfig * @description Resource configuration model for DeepL translation links. @@ -4713,6 +4792,7 @@ export interface components { * @description Events that trigger notifications for this user * @default [ * "messageReceived", + * "newCorrection", * "resourceProposed", * "resourcePublished" * ] @@ -4722,7 +4802,8 @@ export interface components { * Adminnotificationtriggers * @description Events that trigger admin notifications for this user * @default [ - * "userAwaitsActivation" + * "userAwaitsActivation", + * "newCorrection" * ] */ adminNotificationTriggers?: components['schemas']['AdminNotificationTrigger'][]; @@ -4821,7 +4902,11 @@ export interface components { unread: number; }; /** @enum {string} */ - UserNotificationTrigger: 'messageReceived' | 'resourceProposed' | 'resourcePublished'; + UserNotificationTrigger: + | 'messageReceived' + | 'newCorrection' + | 'resourceProposed' + | 'resourcePublished'; /** * UserRead * @description A user registered in the system @@ -4861,6 +4946,7 @@ export interface components { * @description Events that trigger notifications for this user * @default [ * "messageReceived", + * "newCorrection", * "resourceProposed", * "resourcePublished" * ] @@ -4870,7 +4956,8 @@ export interface components { * Adminnotificationtriggers * @description Events that trigger admin notifications for this user * @default [ - * "userAwaitsActivation" + * "userAwaitsActivation", + * "newCorrection" * ] */ adminNotificationTriggers?: components['schemas']['AdminNotificationTrigger'][]; @@ -4935,6 +5022,7 @@ export interface components { * @description Events that trigger notifications for this user * @default [ * "messageReceived", + * "newCorrection", * "resourceProposed", * "resourcePublished" * ] @@ -4944,7 +5032,8 @@ export interface components { * Adminnotificationtriggers * @description Events that trigger admin notifications for this user * @default [ - * "userAwaitsActivation" + * "userAwaitsActivation", + * "newCorrection" * ] */ adminNotificationTriggers?: components['schemas']['AdminNotificationTrigger'][]; @@ -5515,6 +5604,103 @@ export interface operations { }; }; }; + /** + * Create correction + * @description Creates a correction note referring to a specific content + */ + createCorrection: { + requestBody: { + content: { + 'application/json': components['schemas']['CorrectionCreate']; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + content: { + 'application/json': components['schemas']['CorrectionRead']; + }; + }; + /** @description Not Found */ + 404: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Validation Error */ + 422: { + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + /** + * Get corrections + * @description Returns a list of all corrections for a specific resource + */ + getCorrections: { + parameters: { + path: { + resourceId: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + 'application/json': components['schemas']['CorrectionRead'][]; + }; + }; + /** @description Not Found */ + 404: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Validation Error */ + 422: { + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + /** + * Delete correction + * @description Deletes a specific correction note + */ + deleteCorrection: { + parameters: { + path: { + id: string; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + content: never; + }; + /** @description Forbidden */ + 403: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Not Found */ + 404: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Validation Error */ + 422: { + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; /** Find locations */ findLocations: { parameters: { diff --git a/Tekst-Web/src/components/browse/BookmarksWidget.vue b/Tekst-Web/src/components/browse/BookmarksWidget.vue index af2416d3..5dd0e4fe 100644 --- a/Tekst-Web/src/components/browse/BookmarksWidget.vue +++ b/Tekst-Web/src/components/browse/BookmarksWidget.vue @@ -2,7 +2,7 @@ import { type BookmarkRead } from '@/api'; import { usePlatformData } from '@/composables/platformData'; import { useBrowseStore, useStateStore } from '@/stores'; -import { NThing, NIcon, NButton, NList, NListItem } from 'naive-ui'; +import { NThing, NIcon, NButton, NList, NListItem, NFlex } from 'naive-ui'; import { computed, ref } from 'vue'; import { useRouter } from 'vue-router'; import PromptModal from '@/components/generic/PromptModal.vue'; @@ -119,7 +119,7 @@ async function handleBookmarkSelect(bookmark: BookmarkRead) { - +