Skip to content

Commit

Permalink
Merge pull request #342 from apdavison/comments
Browse files Browse the repository at this point in the history
Add "/comments/" endpoint, for commenting on models, validation tests and validation results
  • Loading branch information
apdavison authored Jan 20, 2025
2 parents 6f6f9ea + 7a81899 commit af74285
Show file tree
Hide file tree
Showing 4 changed files with 267 additions and 10 deletions.
9 changes: 6 additions & 3 deletions validation_service_api/validation_service/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,14 @@ async def get_person(self, kg_client):
user_info = await self.get_user_info()
family_name = user_info["family_name"]
given_name = user_info["given_name"]
person = omcore.Person.list(kg_client, family_name=family_name, given_name=given_name, api="nexus", scope="latest")
person = omcore.Person.list(kg_client, family_name=family_name, given_name=given_name, scope="any")
if person:
if isinstance(person, list):
logger.error("Found more than one person with this name")
return None
if len(person) > 1:
logger.error("Found more than one person with this name")
return None
else:
return person[0]
else:
return person
else:
Expand Down
83 changes: 77 additions & 6 deletions validation_service_api/validation_service/data_models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
from uuid import UUID, uuid5
from uuid import UUID, uuid4
from enum import Enum
from typing import List
from typing import List, Optional
from datetime import datetime, timezone, date
import logging
import json
Expand All @@ -12,11 +12,11 @@
from dateutil import parser as date_parser
import requests

from pydantic import BaseModel, HttpUrl, AnyUrl, validator, ValidationError, constr
from pydantic import BaseModel, HttpUrl, AnyUrl, validator, ValidationError, constr, root_validator
from fastapi.encoders import jsonable_encoder
from fastapi import HTTPException, status

from fairgraph import KGProxy, IRI
from fairgraph import KGProxy, KGObject, IRI
from fairgraph.utility import as_list
from fairgraph.errors import ResolutionFailure, AuthenticationError, AuthorizationError
import fairgraph
Expand Down Expand Up @@ -1767,8 +1767,6 @@ def slugify(name):
return slug.lower()




def get_citation_string(citation_data):
authors = citation_data["authors"]
if len(authors) == 1:
Expand All @@ -1789,3 +1787,76 @@ def get_citation_string(citation_data):
pagination = citation_data["pagination"]
date_published = datetime.fromisoformat(citation_data["publication_date"])
return f"{author_str} ({date_published.year}). {title} {journal_name}, {volume_number}: {pagination}."


class PublicationStatus(str, Enum):
draft = "draft"
submitted = "submitted"
published = "published"


class NewComment(BaseModel):
about: UUID
content: str

def to_kg_object(self, kg_client, commenter):
about = KGObject.from_id(str(self.about), kg_client)
# by definition this is a new object, so we create its UUID
# now to avoid taking time for the "exists()" query
id = kg_client.uri_from_uuid(str(uuid4()))
return omcore.Comment(
id=id,
about=about,
comment=self.content,
commenter=commenter,
timestamp=datetime.now(timezone.utc)
)


class Comment(BaseModel):
"""Users may comment on models, validation tests or validation results."""
about: UUID
content: str
commenter: Person
timestamp: datetime
status: PublicationStatus = PublicationStatus.draft
id: UUID = None

@classmethod
def from_kg_object(cls, comment, kg_client):
obj = cls(
about=UUID(comment.about.uuid),
content=comment.comment,
commenter=Person.from_kg_object(comment.commenter, kg_client),
timestamp=comment.timestamp,
id=comment.uuid
)
if comment.space in ("myspace", kg_client._private_space):
obj.status = PublicationStatus.draft
elif comment.is_released(kg_client):
obj.status = PublicationStatus.published
else:
obj.status = PublicationStatus.submitted
return obj

def to_kg_object(self, kg_client):
about = KGObject.from_id(str(self.about), kg_client)
return omcore.Comment(
about=about,
comment=self.content,
commenter=self.commenter.to_kg_object(kg_client),
timestamp=self.timestamp,
id=kg_client.uri_from_uuid(self.id) if self.id else None
)


class CommentPatch(BaseModel):
"""Comments may be edited by their authors."""
content: Optional[str] = None
status: Optional[PublicationStatus] = None

@root_validator()
def check_not_empty(cls, values):
if (values.get('content') is None) and (values.get("status") is None):
raise ValueError('either content or status is required')
return values
3 changes: 2 additions & 1 deletion validation_service_api/validation_service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.cors import CORSMiddleware

from .resources import models, tests, vocab, results, auth
from .resources import models, tests, vocab, results, auth, comments
from . import settings


Expand Down Expand Up @@ -50,4 +50,5 @@
app.include_router(tests.router, tags=["Validation Tests"])
app.include_router(results.router, tags=["Validation Results"])
#app.include_router(simulations.router, tags=["Simulations"])
app.include_router(comments.router, tags=["Comments"])
app.include_router(vocab.router, tags=["Controlled vocabularies"])
182 changes: 182 additions & 0 deletions validation_service_api/validation_service/resources/comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""
"""

from datetime import datetime, timezone
from uuid import UUID
from typing import List
import logging

import fairgraph.openminds.core as omcore
from fairgraph.errors import AuthenticationError

from fastapi import APIRouter, Depends, Query, Path, HTTPException, status as status_codes
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

from ..auth import User, get_kg_client_for_user_account, get_kg_client_for_service_account
from ..data_models import Comment, CommentPatch, NewComment, PublicationStatus

logger = logging.getLogger("validation_service_api")

auth = HTTPBearer(auto_error=False)
router = APIRouter()


@router.get("/comments/", response_model=List[Comment])
async def query_comments(
about: UUID = Query(
None, description="UUID of the object that the comment is about"
),
commenter_orcid: str = Query(
None,
description='<a href="https://orcid.org">ORCID</a> of the person who made the comment',
),
commenter_family_name: str = Query(
None, description="Family name of the person who made the comment"
),
status: PublicationStatus = Query(
None, description="Is the comment a draft, submitted, or published"
),
size: int = Query(20, description="Maximum number of responses"),
from_index: int = Query(0, description="Index of the first response returned"),
# from header
token: HTTPAuthorizationCredentials = Depends(auth),
):
kg_user_client = get_kg_client_for_user_account(token)
filters = {}
if about:
filters["about"] = str(about)
if commenter_orcid:
filters["commenter__digital_identifiers__identifier"] = commenter_orcid
elif commenter_family_name:
filters["commenter__family_name"] = commenter_family_name
try:
kg_comments = omcore.Comment.list(
kg_user_client,
scope="any",
follow_links={"commenter": {}},
api="query",
size=size,
from_index=from_index,
)
except AuthenticationError as err:
raise HTTPException(
status_code=status_codes.HTTP_401_UNAUTHORIZED,
detail="Token not provided, or is invalid, it may have expired."
)
comments = [Comment.from_kg_object(obj, kg_user_client) for obj in kg_comments]
if status:
comments = [c for c in comments if c.status == status]
return comments


@router.post("/comments/", response_model=Comment, status_code=status_codes.HTTP_201_CREATED)
async def create_comment(
comment: NewComment,
token: HTTPAuthorizationCredentials = Depends(auth),
):
kg_user_client = get_kg_client_for_user_account(token)
user = User(token, allow_anonymous=False)
commenter = await user.get_person(kg_user_client)
if commenter is None:
raise HTTPException(
status_code=status_codes.HTTP_404_NOT_FOUND,
detail="This user cannot comment at the moment. Please contact EBRAINS support."
)
obj = comment.to_kg_object(kg_user_client, commenter)
obj.save(kg_user_client, space="myspace", recursive=False)
return Comment.from_kg_object(obj, kg_user_client)


@router.get("/comments/{comment_id}", response_model=Comment)
async def get_comment(
comment_id: UUID = Path(
..., title="Comment ID", description="ID of the comment to be retrieved"
),
token: HTTPAuthorizationCredentials = Depends(auth),
):
"""Retrieve a specific comment identified by a UUID"""
kg_user_client = get_kg_client_for_user_account(token)
obj = omcore.Comment.from_uuid(str(comment_id), kg_user_client, scope="any")
if obj:
return Comment.from_kg_object(obj, kg_user_client)
else:
raise HTTPException(
status_code=status_codes.HTTP_404_NOT_FOUND,
detail=(
f"Either the comment with identifier {comment_id}"
" does not exist or you do not have access to it."
),
)


@router.put("/comments/{comment_id}", response_model=Comment)
async def update_comment(
comment_patch: CommentPatch,
comment_id: UUID = Path(
..., title="Comment ID", description="ID of the comment to be updated"
),
token: HTTPAuthorizationCredentials = Depends(auth),
):
"""Retrieve a specific comment identified by a UUID"""
kg_user_client = get_kg_client_for_user_account(token)
original_comment = omcore.Comment.from_uuid(
str(comment_id), kg_user_client, scope="any"

)

if original_comment.is_released(kg_user_client):
raise HTTPException(
status_code=status_codes.HTTP_403_FORBIDDEN,
detail=f"Published comments cannot be modified",
)
if comment_patch.content:
if omcore.Person.me(kg_user_client).id != original_comment.commenter.id:
raise HTTPException(
status_code=status_codes.HTTP_403_FORBIDDEN,
detail=f"Comments can only be modified by their author",
)
original_comment.comment = comment_patch.content
original_comment.timestamp = datetime.now(timezone.utc)

original_comment.save(kg_user_client, recursive=False)

if comment_patch.status is not None:

if comment_patch.status == PublicationStatus.draft:
target_space = "myspace"
else:
about = original_comment.about.resolve(kg_user_client, scope="any")
target_space = about.space

if original_comment.space != target_space:
try:
# try to move to appropriate space with user client
kg_user_client.move_to_space(original_comment.id, target_space)
except Exception as err:
# move with service client
kg_service_client = get_kg_client_for_service_account()
try:
kg_service_client.move_to_space(original_comment.id, target_space)
except Exception as err2:
logger.error(str(err2))
raise HTTPException(
status_code=status_codes.HTTP_403_FORBIDDEN,
detail=f"You do not have sufficient permissions to publish this comment",
)
else:
original_comment._space = target_space

if comment_patch.status == PublicationStatus.published:
# release
kg_service_client = get_kg_client_for_service_account()
try:
original_comment.release(kg_service_client)
except Exception as err:
logger.error(str(err))
raise HTTPException(
status_code=status_codes.HTTP_403_FORBIDDEN,
detail=f"You do not have sufficient permissions to publish this comment",
)

return Comment.from_kg_object(original_comment, kg_user_client)

0 comments on commit af74285

Please sign in to comment.