Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add status property #1117

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 7 additions & 4 deletions invenio_communities/communities/records/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@
from invenio_vocabularies.records.systemfields.relations import CustomFieldsRelation

from invenio_communities.communities.records.systemfields.children import ChildrenField
from invenio_communities.communities.records.systemfields.is_verified import (
IsVerifiedField,
from invenio_communities.communities.records.systemfields.is_safelisted import (
IsSafelistedField,
)

from ..dumpers.featured import FeaturedDumperExt
from . import models
from .systemfields.access import CommunityAccessField
from .systemfields.community_status import CommunityStatusField
from .systemfields.deletion_status import CommunityDeletionStatusField
from .systemfields.parent_community import ParentCommunityField
from .systemfields.pidslug import PIDSlugField
Expand Down Expand Up @@ -64,7 +65,7 @@ class Community(Record):
extensions=[
FeaturedDumperExt("featured"),
RelationDumperExt("relations"),
CalculatedFieldDumperExt("is_verified"),
CalculatedFieldDumperExt("is_safelisted"),
]
)

Expand Down Expand Up @@ -121,7 +122,9 @@ class Community(Record):
custom=CustomFieldsRelation("COMMUNITIES_CUSTOM_FIELDS"),
)

is_verified = IsVerifiedField("is_verified")
status = CommunityStatusField("status")

is_safelisted = IsSafelistedField("is_safelisted")

deletion_status = CommunityDeletionStatusField()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@
"description": "Whether or not the tombstone page is publicly visible."
}
}
},
"status": {
"type": "string",
"enum": [
"new",
"moderated",
"verified"
]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@
"id": {
"type": "keyword"
},
"is_verified": {
"is_safelisted": {
"type": "boolean"
},
"status": {
"type": "keyword"
},
"slug": {
"type": "keyword"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@
"id": {
"type": "keyword"
},
"is_verified": {
"is_safelisted": {
"type": "boolean"
},
"status": {
"type": "keyword"
},
"slug": {
"type": "keyword"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"id": {
"type": "keyword"
},
"is_verified": {
"is_safelisted": {
"type": "boolean"
},
"slug": {
Expand All @@ -47,6 +47,9 @@
"deletion_status": {
"type": "keyword"
},
"status": {
"type": "keyword"
},
"is_deleted": {
"type": "boolean"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2024 CERN.
#
# Invenio-communities is free software; you can redistribute it and/or modify
# it under the terms of the MIT License; see LICENSE file for more details.
"""Community status system field."""

import enum

from invenio_records.systemfields import SystemField


class CommunityStatusEnum(enum.Enum):
"""Community status enum."""

NEW = "new"
VERIFIED = "verified"
MODERATED = "moderated"


class CommunityStatus:
"""The community status of the community."""

def __init__(self, status):
"""Initialize the community status."""
self.status = status

@property
def status(self):
"""Get the community status."""
return self._status.value

@status.setter
def status(self, value):
"""Set the community status."""
if value is None:
self._status = CommunityStatusEnum.NEW

elif isinstance(value, str):
self._status = CommunityStatusEnum(value)

elif isinstance(value, CommunityStatusEnum):
self._status = value

else:
raise ValueError(f"Invalid value for community community status: {value}")

def __repr__(self):
"""Return repr(self)."""
return f"<CommunityStatus {self._status.name}: '{self._status.value}'>"

def __str__(self):
"""Return str(self)."""
return self.status

def __eq__(self, other):
"""Check if self and other are equal.

This allows checking against other instances of the same type, strings,
and ``CommunityStatusEnum`` values.
"""
if isinstance(other, type(self)):
return self.status == other.status

elif isinstance(other, CommunityStatusEnum):
return self.status == other.value

elif isinstance(other, str):
return self.status == other

return False


class CommunityStatusField(SystemField):
"""System field for the community status."""

#
# Data descriptor methods (i.e. attribute access)
#
def __get__(self, record, owner=None):
"""Get the status of the community."""
if record is None:
return self # returns the field itself.

status = self._get_cache(record) or CommunityStatus(record.get("status"))

self._set_cache(record, status)
return status

def __set__(self, record, value):
"""Set the status of the community."""
status = CommunityStatus(value)
self._set_cache(record, status)

def pre_commit(self, record):
"""Dump the deletion status to the community before committing."""
status = self._get_cache(record) or CommunityStatus(None)
record[self.key] = status.status
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,29 @@

from invenio_records_resources.records.systemfields.calculated import CalculatedField

from ..systemfields.community_status import CommunityStatusEnum

class IsVerifiedField(CalculatedField):

class IsSafelistedField(CalculatedField):
"""Systemfield for calculating whether or not the request is expired."""

def __init__(self, key=None):
"""Constructor."""
super().__init__(key=key, use_cache=False)

def calculate(self, record):
"""Calculate the ``is_verified`` property of the record."""
"""Calculate the ``is_safelisted`` property of the record."""
# import here due to circular import
from invenio_communities.members.records.api import Member

community_verified = False
owners = [m.dumps() for m in Member.get_members(record.id) if m.role == "owner"]
for owner in owners:
# community is considered verified if at least one owner is verified
if owner["user"]["verified_at"] is not None:
community_verified = True
break
community_verified = record.status == CommunityStatusEnum.VERIFIED
if not community_verified:
owners = [
m.dumps() for m in Member.get_members(record.id) if m.role == "owner"
]
for owner in owners:
# community is considered verified if at least one owner is verified
if owner["user"]["verified_at"] is not None:
community_verified = True
break
return community_verified
9 changes: 6 additions & 3 deletions invenio_communities/communities/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,9 @@ class Meta:
unknown = EXCLUDE

field_dump_permissions = {
# hide 'is_verified' behind a permission
"is_verified": "moderate",
# hide 'is_safelisted' behind a permission
"is_safelisted": "moderate",
"status": "moderate",
}

id = fields.String(dump_only=True)
Expand All @@ -229,7 +230,9 @@ class Meta:
partial(CustomFieldsSchema, fields_var="COMMUNITIES_CUSTOM_FIELDS")
)

is_verified = fields.Boolean(dump_only=True)
is_safelisted = fields.Boolean(dump_only=True)

status = fields.String(dump_only=True)

theme = fields.Nested(CommunityThemeSchema, allow_none=True)

Expand Down
2 changes: 1 addition & 1 deletion invenio_communities/communities/services/sort.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ def apply(self, identity, search, params):

if current_app.config["COMMUNITIES_SEARCH_SORT_BY_VERIFIED"]:
fields = self._compute_sort_fields(params)
return search.sort(*["-is_verified", *fields])
return search.sort(*["-is_safelisted", *fields])

return super(CommunitiesSortParam, self).apply(identity, search, params)
2 changes: 1 addition & 1 deletion invenio_communities/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class CommunityPermissionPolicy(BasePermissionPolicy):
can_featured_update = [Administration(), SystemProcess()]
can_featured_delete = [Administration(), SystemProcess()]

# Used to hide at the moment the `is_verified` field. It should be set to
# Used to hide at the moment the `is_safelisted` field. It should be set to
# correct permissions based on which the field will be exposed only to moderators
can_moderate = [Disable()]

Expand Down
35 changes: 35 additions & 0 deletions tests/communities/test_safelist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2024 CERN.
#
# Invenio-communities is free software; you can redistribute it and/or modify
# it under the terms of the MIT License; see LICENSE file for more details.
"""Test safelist feature for communities."""

from copy import deepcopy

from invenio_db import db

from invenio_communities.communities.records.systemfields.community_status import (
CommunityStatusEnum,
)


def test_safelist_computed_by_verified_status(
community_service, minimal_community, location, es_clear, unverified_user
):
"""Test that the safelist feature for communities is computed correctly based on the verified status."""
# Create a comunity
# Flag it as "verified"
# Validate that the computed field "is_verified" is set to "True"
c_data = deepcopy(minimal_community)
c_data["slug"] = "test_status_perms"
c_item = community_service.create(unverified_user.identity, data=c_data)
assert c_item._record.status == CommunityStatusEnum.NEW
assert c_item._record.is_safelisted is False
community = community_service.record_cls.pid.resolve(c_item.id)
community.status = CommunityStatusEnum.VERIFIED
community.commit()
db.session.commit()
c_item = community_service.read(unverified_user.identity, c_item.id)
assert c_item._record.is_safelisted is True
27 changes: 27 additions & 0 deletions tests/communities/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
from invenio_records_resources.services.errors import PermissionDeniedError
from marshmallow import ValidationError

from invenio_communities.communities.records.systemfields.community_status import (
CommunityStatusEnum,
)
from invenio_communities.communities.records.systemfields.deletion_status import (
CommunityDeletionStatusEnum,
)
Expand Down Expand Up @@ -762,3 +765,27 @@ def test_bulk_update_parent_overwrite(
for c_id in children:
c_comm = community_service.record_cls.pid.resolve(c_id)
assert str(c_comm.parent.id) == str(parent_community.id)


def test_status_new(community_service, minimal_community, location, es_clear, owner):
"""Test the status of a new community."""
c_data = deepcopy(minimal_community)
c_data["slug"] = "test_status_new"
co = community_service.create(data=c_data, identity=owner.identity)
assert co._record.status == CommunityStatusEnum.NEW


def test_status_permissions(
community_service, minimal_community, users, location, es_clear, owner
):
"""Test that search does not return the 'status' field to any user."""
c_data = deepcopy(minimal_community)
c_data["slug"] = "test_status_perms"
co = community_service.create(data=c_data, identity=owner.identity)
community_service.record_cls.index.refresh()
assert co._record.status == CommunityStatusEnum.NEW

for uname, u in users.items():
search = community_service.search(u.identity)
assert search.total == 1
assert not any("status" in hit for hit in search.hits)
16 changes: 15 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from copy import deepcopy

import pytest
from flask_principal import AnonymousIdentity
from flask_principal import AnonymousIdentity, Need
from invenio_access.models import ActionRoles
from invenio_access.permissions import any_user as any_user_need
from invenio_access.permissions import superuser_access
Expand Down Expand Up @@ -251,6 +251,20 @@ def owner(users):
return users["owner"]


@pytest.fixture()
def unverified_user(UserFixture, app, db):
"""User meant to test 'verified' property of records."""
u = UserFixture(
email="unverified@inveniosoftware.org",
password="testuser",
)
u.create(app, db)
u.user.verified_at = None
# Dumping `is_verified` requires authenticated user in tests
u.identity.provides.add(Need(method="system_role", value="authenticated_user"))
return u


@pytest.fixture(scope="module")
def any_user(UserFixture, app, database):
"""A user without privileges or memberships."""
Expand Down