From bf010e1a99161ac49892ecd797254714be3b2dc6 Mon Sep 17 00:00:00 2001 From: quaxsze Date: Wed, 21 Feb 2024 15:40:38 -0300 Subject: [PATCH 1/3] Add team logic to Udata --- udata/core/organization/api.py | 87 +++++++++++++++++++++++ udata/core/organization/factories.py | 9 +-- udata/core/organization/models.py | 17 ++--- udata/core/team/__init__.py | 0 udata/core/team/api_fields.py | 16 +++++ udata/core/team/factories.py | 13 ++++ udata/core/team/forms.py | 17 +++++ udata/core/team/models.py | 29 ++++++++ udata/models/__init__.py | 1 + udata/models/owned.py | 6 +- udata/routing.py | 5 ++ udata/tests/api/test_organizations_api.py | 47 +++++++++++- 12 files changed, 225 insertions(+), 22 deletions(-) create mode 100644 udata/core/team/__init__.py create mode 100644 udata/core/team/api_fields.py create mode 100644 udata/core/team/factories.py create mode 100644 udata/core/team/forms.py create mode 100644 udata/core/team/models.py diff --git a/udata/core/organization/api.py b/udata/core/organization/api.py index b89afce065..25440826f1 100644 --- a/udata/core/organization/api.py +++ b/udata/core/organization/api.py @@ -8,6 +8,10 @@ from udata.auth import admin_permission, current_user from udata.core.badges import api as badges_api from udata.core.followers.api import FollowAPI +from udata.core.user.api_fields import user_fields +from udata.core.team.api_fields import team_fields +from udata.core.team.forms import TeamForm +from udata.core.team.models import Team from udata.utils import multi_to_dict from udata.rdf import ( RDF_EXTENSIONS, negociate_content, graph_response @@ -384,6 +388,89 @@ def delete(self, org, user): api.abort(404) +@ns.route('//teams/', endpoint='teams') +class TeamsAPI(API): + @api.secure + @api.expect(team_fields) + @api.marshal_with(team_fields, code=201) + @api.doc('create_organization_team') + @api.response(403, 'Not Authorized') + @api.response(409, 'Team already exists', team_fields) + def post(self, org): + '''Create a team for a given organization.''' + EditOrganizationPermission(org).test() + form = api.validate(TeamForm) + team = Team() + form.populate_obj(team) + org.teams.append(team) + team.save() + org.save() + return team, 201 + + +@ns.route('//teams//', endpoint='team') +class TeamAPI(API): + @api.doc('get_team') + @api.marshal_with(team_fields) + def get(self, org, team): + '''Get a team given its identifier''' + if org.deleted: + api.abort(410, 'Organization has been deleted') + return team + + @api.secure + @api.doc('update_team') + @api.expect(team_fields) + @api.marshal_with(team_fields) + def put(self, org, team): + '''Update a given team on a given organization''' + EditOrganizationPermission(org).test() + form = api.validate(TeamForm, team) + form.populate_obj(team) + team.save() + return team + + @api.secure + @api.doc('delete_team') + def delete(self, org, team): + '''Delete a given team on a given organization''' + EditOrganizationPermission(org).test() + org.teams.remove(team) + org.save() + team.delete() + return '', 204 + + +@ns.route('//teams//member/', endpoint='team_member') +class TeamMemberAPI(API): + @api.secure + @api.marshal_with(user_fields, code=201) + @api.doc('create_organization_team_member') + def post(self, org, team, user): + '''Add a member into a given organization's team''' + EditOrganizationPermission(org).test() + if not org.is_member(user): + api.abort(403, 'User is not a member of the organization') + team.members.append(user) + team.save() + return user, 201 + + @api.secure + @api.doc('delete_organization_team_member') + def delete(self, org, team, user): + '''Delete member from an organization's team''' + EditOrganizationPermission(org).test() + if not org.is_member(user): + api.abort(403, 'User is not a member of the organization') + member = team.member(user) + if member: + team.remove(user) + team.save() + return '', 204 + else: + api.abort(404) + + @ns.route('//followers/', endpoint='organization_followers') @ns.doc(get={'id': 'list_organization_followers'}, post={'id': 'follow_organization'}, diff --git a/udata/core/organization/factories.py b/udata/core/organization/factories.py index c3bfeec80d..50e7965707 100644 --- a/udata/core/organization/factories.py +++ b/udata/core/organization/factories.py @@ -2,7 +2,7 @@ from udata.factories import ModelFactory -from .models import Organization, Team, Member +from .models import Organization, Member class OrganizationFactory(ModelFactory): @@ -22,10 +22,3 @@ class Meta: class Params: admins = [] editors = [] - - -class TeamFactory(ModelFactory): - class Meta: - model = Team - - name = factory.Faker('sentence') diff --git a/udata/core/organization/models.py b/udata/core/organization/models.py index b7349d7930..12cc64e1dc 100644 --- a/udata/core/organization/models.py +++ b/udata/core/organization/models.py @@ -13,7 +13,7 @@ __all__ = ( - 'Organization', 'Team', 'Member', 'MembershipRequest', + 'Organization', 'Member', 'MembershipRequest', 'ORG_ROLES', 'MEMBERSHIP_STATUS', 'PUBLIC_SERVICE', 'CERTIFIED' ) @@ -43,15 +43,10 @@ ORG_BID_SIZE_LIMIT = 14 -class Team(db.EmbeddedDocument): - name = db.StringField(required=True) - slug = db.SlugField( - max_length=255, required=True, populate_from='name', update=True, - unique=False) - description = db.StringField() - - members = db.ListField(db.ReferenceField('User')) - +# Si dataset a un owner -> verifie que c'est bien l'utilisateur +# Si dataset dans orga: +# Si admin c'est bon +# Sinon voir si dans la bonne équipe correspondante au dataset class Member(db.EmbeddedDocument): user = db.ReferenceField('User') @@ -109,8 +104,8 @@ class Organization(WithMetrics, BadgeMixin, db.Datetimed, db.Document): business_number_id = db.StringField(max_length=ORG_BID_SIZE_LIMIT) members = db.ListField(db.EmbeddedDocumentField(Member)) - teams = db.ListField(db.EmbeddedDocumentField(Team)) requests = db.ListField(db.EmbeddedDocumentField(MembershipRequest)) + teams = db.ListField(db.ReferenceField('Team')) ext = db.MapField(db.GenericEmbeddedDocumentField()) zone = db.StringField() diff --git a/udata/core/team/__init__.py b/udata/core/team/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/udata/core/team/api_fields.py b/udata/core/team/api_fields.py new file mode 100644 index 0000000000..a2f216b914 --- /dev/null +++ b/udata/core/team/api_fields.py @@ -0,0 +1,16 @@ +from udata.api import api, fields +from udata.core.user.api_fields import user_fields + + +team_fields = api.model('Team', { + 'id': fields.String( + description='The team identifier', required=True), + 'name': fields.String(description='The team name', required=True), + 'slug': fields.String( + description='The team string used as permalink', + required=True), + 'description': fields.Markdown( + description='The team description in Markdown', required=True), + 'members': fields.List( + fields.Nested(user_fields, description='The team members')) +}) diff --git a/udata/core/team/factories.py b/udata/core/team/factories.py new file mode 100644 index 0000000000..c8d2468d78 --- /dev/null +++ b/udata/core/team/factories.py @@ -0,0 +1,13 @@ +import factory + +from udata.factories import ModelFactory + +from .models import Team + + +class TeamFactory(ModelFactory): + class Meta: + model = Team + + name = factory.Faker('sentence') + description = factory.Faker('text') diff --git a/udata/core/team/forms.py b/udata/core/team/forms.py new file mode 100644 index 0000000000..5bb91c81b5 --- /dev/null +++ b/udata/core/team/forms.py @@ -0,0 +1,17 @@ +from udata.forms import ModelForm, fields, validators +from udata.i18n import lazy_gettext as _ + +from .models import ( + TITLE_SIZE_LIMIT, DESCRIPTION_SIZE_LIMIT +) + +__all__ = ( + 'TeamForm' +) + + +class TeamForm(ModelForm): + name = fields.StringField(_('Name'), [validators.DataRequired(), validators.Length(max=TITLE_SIZE_LIMIT)]) + description = fields.MarkdownField( + _('Description'), [validators.DataRequired(), validators.Length(max=DESCRIPTION_SIZE_LIMIT)], + description=_('The details about your team')) diff --git a/udata/core/team/models.py b/udata/core/team/models.py new file mode 100644 index 0000000000..ddbd544f6f --- /dev/null +++ b/udata/core/team/models.py @@ -0,0 +1,29 @@ +from udata.models import db + + +__all__ = ( + 'Team', 'TITLE_SIZE_LIMIT', 'DESCRIPTION_SIZE_LIMIT' +) + + +TITLE_SIZE_LIMIT = 350 +DESCRIPTION_SIZE_LIMIT = 100000 + + +class Team(db.Document): + name = db.StringField(required=True) + slug = db.SlugField( + max_length=255, required=True, populate_from='name', update=True, + unique=False) + description = db.StringField() + + members = db.ListField(db.ReferenceField('User')) + + def member(self, user): + for member in self.members: + if member == user: + return member + return None + + def is_member(self, user): + return self.member(user) is not None diff --git a/udata/models/__init__.py b/udata/models/__init__.py index 9cc2eddc4b..83e99ccace 100644 --- a/udata/models/__init__.py +++ b/udata/models/__init__.py @@ -89,6 +89,7 @@ def resolve_model(self, model): from udata.core.followers.models import * # noqa from udata.core.user.models import * # noqa from udata.core.organization.models import * # noqa +from udata.core.team.models import * # noqa from udata.core.contact_point.models import * # noqa from udata.core.site.models import * # noqa from udata.core.dataset.models import * # noqa diff --git a/udata/models/owned.py b/udata/models/owned.py index f8c69bc435..91ced82f85 100644 --- a/udata/models/owned.py +++ b/udata/models/owned.py @@ -2,7 +2,7 @@ from blinker import signal from mongoengine import NULLIFY, Q, post_save -from mongoengine.fields import ReferenceField +from mongoengine.fields import ReferenceField, ListField from .queryset import UDataQuerySet @@ -23,6 +23,7 @@ class Owned(object): ''' owner = ReferenceField('User', reverse_delete_rule=NULLIFY) organization = ReferenceField('Organization', reverse_delete_rule=NULLIFY) + teams = ListField(ReferenceField('Team', reverse_delete_rule=NULLIFY)) on_owner_change = signal('Owned.on_owner_change') @@ -30,10 +31,11 @@ class Owned(object): 'indexes': [ 'owner', 'organization', + 'teams', ], 'queryset_class': OwnedQuerySet, } - + # TODO: Update clean with teams validation def clean(self): ''' Verify owner consistency and fetch original owner before the new one erase it. diff --git a/udata/routing.py b/udata/routing.py index 2c0eb59b19..35929bcdea 100644 --- a/udata/routing.py +++ b/udata/routing.py @@ -149,6 +149,10 @@ class ContactPointConverter(ModelConverter): model = models.ContactPoint +class TeamConverter(ModelConverter): + model = models.Team + + class TerritoryConverter(PathConverter): DEFAULT_PREFIX = 'fr' # TODO: make it a setting parameter @@ -224,6 +228,7 @@ def init_app(app): app.url_map.converters['dataset'] = DatasetConverter app.url_map.converters['crid'] = CommunityResourceConverter app.url_map.converters['org'] = OrganizationConverter + app.url_map.converters['team'] = TeamConverter app.url_map.converters['reuse'] = ReuseConverter app.url_map.converters['user'] = UserConverter app.url_map.converters['topic'] = TopicConverter diff --git a/udata/tests/api/test_organizations_api.py b/udata/tests/api/test_organizations_api.py index d39c5dabe1..3e2b1afa1b 100644 --- a/udata/tests/api/test_organizations_api.py +++ b/udata/tests/api/test_organizations_api.py @@ -1,3 +1,4 @@ +import json import pytest from datetime import datetime @@ -5,13 +6,14 @@ from flask import url_for from udata.models import ( - Organization, Member, MembershipRequest, Follow, Discussion + Organization, Member, MembershipRequest, Follow, Discussion, Team ) from udata.utils import faker from udata.core.badges.factories import badge_factory from udata.core.badges.signals import on_badge_added, on_badge_removed from udata.core.organization.factories import OrganizationFactory +from udata.core.team.factories import TeamFactory from udata.core.user.factories import UserFactory, AdminFactory from udata.core.dataset.factories import DatasetFactory from udata.core.reuse.factories import ReuseFactory @@ -870,3 +872,46 @@ def test_org_contact_points(self, api): assert response.json['data'][0]['name'] == data['name'] assert response.json['data'][0]['email'] == data['email'] + + +class OrganizationTeamsAPITest: + modules = [] + + def test_get(self, api): + '''It should fetch a team from the API''' + user = api.login() + member = Member(user=user, role='admin') + org = OrganizationFactory(members=[member]) + team = TeamFactory(members=[user]) + org.teams.append(team) + org.save() + response = api.get(url_for('api.team', org=org, team=team)) + assert200(response) + data = json.loads(response.data) + assert data['name'] == team.name + + def test_create(self, api): + user = api.login() + member = Member(user=user, role='admin') + org = OrganizationFactory(members=[member]) + data = TeamFactory.as_dict() + response = api.post(url_for('api.teams', org=org), data) + assert201(response) + org.reload() + assert len(org.teams) == 1 + assert org.teams[0].name == data['name'] + assert Team.objects.first().name == data['name'] + + def test_delete(self, api): + user = api.login() + member = Member(user=user, role='admin') + org = OrganizationFactory(members=[member]) + team = TeamFactory(members=[user]) + org.teams.append(team) + org.save() + + response = api.delete(url_for('api.team', org=org, team=team)) + assert204(response) + org.reload() + assert len(org.teams) == 0 + assert Team.objects.first() == None From a0b9caab82386f7af194a34a132a95f640889ac9 Mon Sep 17 00:00:00 2001 From: quaxsze Date: Mon, 26 Feb 2024 14:01:16 -0300 Subject: [PATCH 2/3] Add dataset to teams --- udata/core/organization/api.py | 42 ++++++++++++++++ udata/tests/api/test_organizations_api.py | 61 ++++++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/udata/core/organization/api.py b/udata/core/organization/api.py index 25440826f1..37abe2ce3b 100644 --- a/udata/core/organization/api.py +++ b/udata/core/organization/api.py @@ -471,6 +471,48 @@ def delete(self, org, team, user): api.abort(404) +@ns.route('//teams//datasets', endpoint='team_datasets') +class TeamDatasetAPI(API): + @api.secure + @api.marshal_with(team_fields, code=201) + @api.doc('create_organization_team_datasets') + def post(self, org, team): + '''Add a list of datasets to a organization's team''' + EditOrganizationPermission(org).test() + data = request.json + datasets = data.get('datasets', []) + for dataset_id in datasets: + dataset = Dataset.objects(id=dataset_id).first() + if not dataset: + api.abort(404, f'Dataset {dataset_id} not found') + if not dataset.organization == org: + api.abort(403, f'Dataset {dataset_id} is not part of the organization') + dataset.teams.append(team) + dataset.save() + return team, 201 + + @api.secure + @api.marshal_with(team_fields, code=201) + @api.doc('delete_organization_team_datasets') + def delete(self, org, team): + '''remove a list of datasets to a organization's team''' + EditOrganizationPermission(org).test() + data = request.json + datasets = data.get('datasets', []) + for dataset_id in datasets: + dataset = Dataset.objects(id=dataset_id).first() + if not dataset: + api.abort(404, f'Dataset {dataset_id} not found') + if not dataset.organization == org: + api.abort(403, f'Dataset {dataset_id} is not part of the organization') + try: + dataset.teams.remove(team) + except ValueError: + api.abort(400, f'Dataset {dataset_id} is not part of the team') + dataset.save() + return team, 204 + + @ns.route('//followers/', endpoint='organization_followers') @ns.doc(get={'id': 'list_organization_followers'}, post={'id': 'follow_organization'}, diff --git a/udata/tests/api/test_organizations_api.py b/udata/tests/api/test_organizations_api.py index 3e2b1afa1b..69e6fbfc81 100644 --- a/udata/tests/api/test_organizations_api.py +++ b/udata/tests/api/test_organizations_api.py @@ -914,4 +914,63 @@ def test_delete(self, api): assert204(response) org.reload() assert len(org.teams) == 0 - assert Team.objects.first() == None + assert Team.objects.first() is None + + def test_add_dataset_to_team(self, api): + user = api.login() + member = Member(user=user, role='admin') + org = OrganizationFactory(members=[member]) + team = TeamFactory(members=[user]) + dataset = DatasetFactory(organization=org) + org.teams.append(team) + org.save() + + response = api.post(url_for('api.team_datasets', org=org, team=team), {'datasets': [str(dataset.id)]}) + assert201(response) + dataset.reload() + assert len(dataset.teams) == 1 + assert dataset.teams[0].name == team.name + + def test_add_dataset_to_team_failing(self, api): + user = api.login() + member = Member(user=user, role='admin') + org = OrganizationFactory(members=[member]) + team = TeamFactory(members=[user]) + dataset = DatasetFactory() + org.teams.append(team) + org.save() + + response = api.post(url_for('api.team_datasets', org=org, team=team), {'datasets': [str(dataset.id)]}) + assert403(response) + + def test_delete_dataset_from_team(self, api): + user = api.login() + member = Member(user=user, role='admin') + org = OrganizationFactory(members=[member]) + team = TeamFactory(members=[user]) + dataset = DatasetFactory(organization=org) + org.teams.append(team) + org.save() + + response = api.post(url_for('api.team_datasets', org=org, team=team), {'datasets': [str(dataset.id)]}) + assert201(response) + dataset.reload() + assert len(dataset.teams) == 1 + assert dataset.teams[0].name == team.name + + response = api.delete(url_for('api.team_datasets', org=org, team=team), {'datasets': [str(dataset.id)]}) + assert204(response) + dataset.reload() + assert len(dataset.teams) == 0 + + def test_delete_dataset_from_team_failing(self, api): + user = api.login() + member = Member(user=user, role='admin') + org = OrganizationFactory(members=[member]) + team = TeamFactory(members=[user]) + dataset = DatasetFactory(organization=org) + org.teams.append(team) + org.save() + + response = api.delete(url_for('api.team_datasets', org=org, team=team), {'datasets': [str(dataset.id)]}) + assert400(response) From 2c35e3c0a0b00865977f01cf62f26bd5adf71dd5 Mon Sep 17 00:00:00 2001 From: quaxsze Date: Tue, 27 Feb 2024 08:20:04 -0300 Subject: [PATCH 3/3] Add dataset verification --- udata/core/dataset/api.py | 10 ++++++++++ udata/core/dataset/models.py | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/udata/core/dataset/api.py b/udata/core/dataset/api.py index 04bb0bab96..994e107ae0 100644 --- a/udata/core/dataset/api.py +++ b/udata/core/dataset/api.py @@ -227,6 +227,11 @@ def put(self, dataset): request_deleted = request.json.get('deleted', True) if dataset.deleted and request_deleted is not None: api.abort(410, 'Dataset has been deleted') + # This line will prevent a user to modify a dataset + # if he is not part of the dataset's team. + # Will be use for organization's team feature. + # if not dataset.is_user_in_dataset_team(current_user): + # api.abort(403, 'User has no right to modify this dataset') DatasetEditPermission(dataset).test() dataset.last_modified_internal = datetime.utcnow() form = api.validate(DatasetForm, dataset) @@ -244,6 +249,11 @@ def delete(self, dataset): '''Delete a dataset given its identifier''' if dataset.deleted: api.abort(410, 'Dataset has been deleted') + # This line will prevent a user to modify a dataset + # if he is not part of the dataset's team. + # Will be use for organization's team feature. + # if not dataset.is_user_in_dataset_team(current_user): + # api.abort(403, 'User has no right to modify this dataset') DatasetEditPermission(dataset).test() dataset.deleted = datetime.utcnow() dataset.last_modified_internal = datetime.utcnow() diff --git a/udata/core/dataset/models.py b/udata/core/dataset/models.py index f97a44d85e..e7c9ad2f38 100644 --- a/udata/core/dataset/models.py +++ b/udata/core/dataset/models.py @@ -593,6 +593,15 @@ def is_hidden(self): return (len(self.resources) == 0 or self.private or self.deleted or self.archived) + @property + def is_user_in_dataset_team(self, user): + permission = False + if self.organization: + for team in self.teams: + if team.is_member(user): + permission = True + return permission + @property def full_title(self): if not self.acronym: