Skip to content

Commit 5d1f0f7

Browse files
authored
Merge branch 'review-individuals' of 'https://github.com/evamillan/grimoirelab-sortinghat'
Merges #923 Closes #923
2 parents 1754819 + 92104ce commit 5d1f0f7

20 files changed

+961
-42
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
title: Mark individuals as reviewed
3+
category: added
4+
author: Eva Millán <evamillan@bitergia.com>
5+
issue: null
6+
notes: >
7+
Individuals can now be marked as reviewed to keep track
8+
of which profiles have already been checked and when.
9+
A profile can be marked as reviewed more than once, it
10+
will show the date of the last review. If there have
11+
been any changes to the profile data since the last review,
12+
the review button displays a warning icon.
13+
14+
The list of individuals can be filtered by whether they
15+
have been reviewed and by their review date. The list can
16+
also be ordered by review date.
17+

sortinghat/cli/client/schema.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ class GroupFilterType(sgqlc.types.Input):
8282

8383
class IdentityFilterType(sgqlc.types.Input):
8484
__schema__ = sh_schema
85-
__field_names__ = ('uuid', 'term', 'is_locked', 'is_bot', 'gender', 'country', 'source', 'enrollment', 'enrollment_parent_org', 'enrollment_date', 'is_enrolled', 'last_updated')
85+
__field_names__ = ('uuid', 'term', 'is_locked', 'is_bot', 'gender', 'country', 'source', 'enrollment', 'enrollment_parent_org', 'enrollment_date', 'is_enrolled', 'last_updated', 'last_reviewed', 'is_reviewed')
8686
uuid = sgqlc.types.Field(String, graphql_name='uuid')
8787
term = sgqlc.types.Field(String, graphql_name='term')
8888
is_locked = sgqlc.types.Field(Boolean, graphql_name='isLocked')
@@ -95,6 +95,8 @@ class IdentityFilterType(sgqlc.types.Input):
9595
enrollment_date = sgqlc.types.Field(String, graphql_name='enrollmentDate')
9696
is_enrolled = sgqlc.types.Field(Boolean, graphql_name='isEnrolled')
9797
last_updated = sgqlc.types.Field(String, graphql_name='lastUpdated')
98+
last_reviewed = sgqlc.types.Field(String, graphql_name='lastReviewed')
99+
is_reviewed = sgqlc.types.Field(Boolean, graphql_name='isReviewed')
98100

99101

100102
class OperationFilterType(sgqlc.types.Input):
@@ -350,14 +352,15 @@ class IdentityType(sgqlc.types.Type):
350352

351353
class IndividualType(sgqlc.types.Type):
352354
__schema__ = sh_schema
353-
__field_names__ = ('created_at', 'last_modified', 'mk', 'is_locked', 'identities', 'profile', 'enrollments')
355+
__field_names__ = ('created_at', 'last_modified', 'mk', 'is_locked', 'identities', 'profile', 'enrollments', 'last_reviewed')
354356
created_at = sgqlc.types.Field(sgqlc.types.non_null(DateTime), graphql_name='createdAt')
355357
last_modified = sgqlc.types.Field(sgqlc.types.non_null(DateTime), graphql_name='lastModified')
356358
mk = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='mk')
357359
is_locked = sgqlc.types.Field(sgqlc.types.non_null(Boolean), graphql_name='isLocked')
358360
identities = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null(IdentityType))), graphql_name='identities')
359361
profile = sgqlc.types.Field('ProfileType', graphql_name='profile')
360362
enrollments = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null(EnrollmentType))), graphql_name='enrollments')
363+
last_reviewed = sgqlc.types.Field(String, graphql_name='lastReviewed')
361364

362365

363366
class JobPaginatedType(sgqlc.types.Type):
@@ -615,6 +618,13 @@ class Refresh(sgqlc.types.Type):
615618
token = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='token')
616619

617620

621+
class Review(sgqlc.types.Type):
622+
__schema__ = sh_schema
623+
__field_names__ = ('uuid', 'individual')
624+
uuid = sgqlc.types.Field(String, graphql_name='uuid')
625+
individual = sgqlc.types.Field(IndividualType, graphql_name='individual')
626+
627+
618628
class SortingHatMutation(sgqlc.types.Type):
619629
__schema__ = sh_schema
620630
__field_names__ = ('add_organization', 'delete_organization', 'add_team', 'delete_team', 'add_domain', 'delete_domain', 'add_identity', 'delete_identity', 'update_profile', 'move_identity', 'lock', 'unlock', 'merge', 'unmerge_identities', 'enroll', 'withdraw', 'update_enrollment', 'recommend_affiliations', 'recommend_matches', 'recommend_gender', 'affiliate', 'unify', 'genderize', 'add_recommender_exclusion_term', 'delete_recommender_exclusion_term', 'token_auth', 'verify_token', 'refresh_token')
@@ -832,6 +842,13 @@ class SortingHatMutation(sgqlc.types.Type):
832842
('term', sgqlc.types.Arg(String, graphql_name='term', default=None)),
833843
))
834844
)
845+
review = sgqlc.types.Field(
846+
Review,
847+
graphql_name='review',
848+
args=sgqlc.types.ArgDict((
849+
('uuid', sgqlc.types.Arg(String, graphql_name='uuid', default=None)),
850+
))
851+
)
835852
token_auth = sgqlc.types.Field(
836853
ObtainJSONWebToken,
837854
graphql_name='tokenAuth',

sortinghat/core/api.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
import logging
2424

25-
from grimoirelab_toolkit.datetime import datetime_to_utc
25+
from grimoirelab_toolkit.datetime import datetime_to_utc, datetime_utcnow
2626

2727
from .db import (find_individual_by_uuid,
2828
find_identity,
@@ -57,7 +57,8 @@
5757
delete_enrollment,
5858
move_domain,
5959
move_team,
60-
move_alias)
60+
move_alias,
61+
review as review_db)
6162
from .errors import (InvalidValueError,
6263
AlreadyExistsError,
6364
NotFoundError,
@@ -1639,3 +1640,41 @@ def delete_merge_recommendations(ctx):
16391640
logger.info("Merge recommendations deleted")
16401641

16411642
return recommendations
1643+
1644+
1645+
@atomic_using_tenant
1646+
def review(ctx, uuid):
1647+
"""Mark an individual as reviewed.
1648+
1649+
This function sets the individual identified by `uuid` as last
1650+
reviewed on the current date.
1651+
1652+
:param ctx: context from where this method is called
1653+
:param uuid: identifier of the individual which will be reviewed
1654+
1655+
:returns: an individual with its last review value updated
1656+
1657+
:raises InvalidValueError: raised when `uuid` is `None` or an empty string
1658+
:raises NotFoundError: when the identity with the
1659+
given `uuid` does not exist.
1660+
"""
1661+
if uuid is None:
1662+
raise InvalidValueError(msg="'uuid' cannot be None")
1663+
if uuid == '':
1664+
raise InvalidValueError(msg="'uuid' cannot be an empty string")
1665+
1666+
trxl = TransactionsLog.open('review', ctx)
1667+
1668+
try:
1669+
individual = find_individual_by_uuid(uuid)
1670+
except NotFoundError as exc:
1671+
raise exc
1672+
1673+
review_date = datetime_utcnow()
1674+
individual = review_db(trxl, individual, review_date)
1675+
1676+
trxl.close()
1677+
1678+
logger.info(f"Individual {uuid} successfully updated")
1679+
1680+
return individual

sortinghat/core/db.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,3 +1352,35 @@ def delete_merge_recommendations(trxl, recommendations):
13521352
trxl.log_operation(op_type=Operation.OpType.DELETE, entity_type='merge_recommendation',
13531353
timestamp=datetime_utcnow(), args=op_args,
13541354
target='merge_recommendations')
1355+
1356+
1357+
def review(trxl, individual, review_date):
1358+
"""Mark a given individual as reviewed.
1359+
1360+
Sets the given `review_date` as the given `individual` last reviewed
1361+
date.
1362+
1363+
:param trxl: TransactionsLog object from the method calling this one
1364+
:param individual: individual to review
1365+
:param review_date: date of the last review
1366+
1367+
:returns: the individual with last_reviewed parameter updated
1368+
"""
1369+
if individual.is_locked:
1370+
raise LockedIdentityError(uuid=individual.mk)
1371+
1372+
op_args = {
1373+
'mk': individual.mk,
1374+
'last_reviewed': copy.deepcopy(str(review_date))
1375+
}
1376+
try:
1377+
individual.last_reviewed = review_date
1378+
individual.save()
1379+
except django.db.utils.IntegrityError as exc:
1380+
_handle_integrity_error(Individual, exc)
1381+
1382+
trxl.log_operation(op_type=Operation.OpType.UPDATE, entity_type='individual',
1383+
timestamp=datetime_utcnow(), args=op_args,
1384+
target=op_args['mk'])
1385+
1386+
return individual
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 4.2.13 on 2024-08-29 15:19
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0009_tenant_perm_group'),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name='individual',
15+
options={'ordering': ('last_modified', 'created_at', 'profile__name', 'last_reviewed')},
16+
),
17+
migrations.AddField(
18+
model_name='individual',
19+
name='last_reviewed',
20+
field=models.DateTimeField(default=None, null=True),
21+
),
22+
]

sortinghat/core/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,10 +243,11 @@ def __str__(self):
243243
class Individual(EntityBase):
244244
mk = CharField(max_length=MAX_SIZE_CHAR_FIELD, primary_key=True)
245245
is_locked = BooleanField(default=False)
246+
last_reviewed = DateTimeField(null=True, default=None)
246247

247248
class Meta:
248249
db_table = 'individuals'
249-
ordering = ('last_modified', 'created_at', 'profile__name',)
250+
ordering = ('last_modified', 'created_at', 'profile__name', 'last_reviewed')
250251

251252
def __str__(self):
252253
return self.mk

sortinghat/core/schema.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@
6464
update_enrollment,
6565
merge_organizations,
6666
delete_scheduled_task,
67-
update_scheduled_task)
67+
update_scheduled_task,
68+
review)
6869
from .context import SortingHatContext
6970
from .decorators import (check_auth, check_permissions)
7071
from .errors import InvalidFilterError, EqualIndividualError, InvalidValueError
@@ -490,6 +491,16 @@ class IdentityFilterType(graphene.InputObjectType):
490491
two dates, following ISO-8601 format. Examples:\n* `>=2020-10-12T09:35:06.13045+01:00` \
491492
\n * `2020-10-12T00:00:00..2020-11-22T00:00:00`.'
492493
)
494+
is_reviewed = graphene.Boolean(
495+
required=False,
496+
description='Filters individuals by whether they have been marked as reviewed.'
497+
)
498+
last_reviewed = graphene.String(
499+
required=False,
500+
description='Filter with a comparison operator (>, >=, <, <=) and a date OR with a range operator (..) between\
501+
two dates, following ISO-8601 format. Examples:\n* `>=2020-10-12T09:35:06.13045+01:00` \
502+
\n * `2020-10-12T00:00:00..2020-11-22T00:00:00`.'
503+
)
493504

494505

495506
class TransactionFilterType(graphene.InputObjectType):
@@ -1572,6 +1583,27 @@ def mutate(self, info, task_id, data):
15721583
)
15731584

15741585

1586+
class Review(graphene.Mutation):
1587+
class Arguments:
1588+
uuid = graphene.String()
1589+
1590+
uuid = graphene.Field(lambda: graphene.String)
1591+
individual = graphene.Field(lambda: IndividualType)
1592+
1593+
@check_permissions(['core.change_profile'])
1594+
def mutate(self, info, uuid):
1595+
user = info.context.user
1596+
tenant = get_db_tenant()
1597+
ctx = SortingHatContext(user=user, tenant=tenant)
1598+
1599+
individual = review(ctx, uuid)
1600+
1601+
return Review(
1602+
uuid=uuid,
1603+
individual=individual
1604+
)
1605+
1606+
15751607
class SortingHatQuery:
15761608

15771609
countries = graphene.Field(
@@ -1870,6 +1902,35 @@ def resolve_individuals(self, info, filters=None,
18701902
elif operator == '..':
18711903
query = query.filter(last_modified__range=(date1, date2))
18721904

1905+
if filters and 'is_reviewed' in filters:
1906+
query = query.filter(mk__in=Subquery(Individual.objects
1907+
.filter(last_reviewed__isnull=not filters['is_reviewed'])
1908+
.values_list('mk')))
1909+
1910+
if filters and 'last_reviewed' in filters:
1911+
# Accepted date format is ISO 8601, YYYY-MM-DDTHH:MM:SS
1912+
try:
1913+
filter_data = parse_date_filter(filters['last_reviewed'])
1914+
except ValueError as e:
1915+
raise InvalidFilterError(filter_name='last_reviewed', msg=e)
1916+
except InvalidDateError as e:
1917+
raise InvalidFilterError(filter_name='last_reviewed', msg=e)
1918+
1919+
date1 = filter_data['date1']
1920+
date2 = filter_data['date2']
1921+
if filter_data['operator']:
1922+
operator = filter_data['operator']
1923+
if operator == '<':
1924+
query = query.filter(last_reviewed__lt=date1)
1925+
elif operator == '<=':
1926+
query = query.filter(last_reviewed__lte=date1)
1927+
elif operator == '>':
1928+
query = query.filter(last_reviewed__gt=date1)
1929+
elif operator == '>=':
1930+
query = query.filter(last_reviewed__gte=date1)
1931+
elif operator == '..':
1932+
query = query.filter(last_reviewed__range=(date1, date2))
1933+
18731934
return IdentityPaginatedType.create_paginated_result(query,
18741935
page,
18751936
page_size=page_size)
@@ -2217,6 +2278,9 @@ class SortingHatMutation(graphene.ObjectType):
22172278
delete_merge_recommendations = DeleteMergeRecommendations.Field(
22182279
description='Remove all unapplied merge recommendations.'
22192280
)
2281+
review = Review.Field(
2282+
description='Mark an individual as reviewed on the current date.'
2283+
)
22202284

22212285
# JWT authentication
22222286
token_auth = graphql_jwt.ObtainJSONWebToken.Field()

0 commit comments

Comments
 (0)