Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion udata/api_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ def constructor_read(**kwargs):
return GenericField({k: v[0].model for k, v in generic_fields.items()}, **kwargs)

def constructor_write(**kwargs):
return GenericField({k: v[1].model for k, v in generic_fields.items()}, **kwargs)
return restx_fields.Nested(lazy_reference, **kwargs)
else:

def constructor(**kwargs):
Expand Down
2 changes: 2 additions & 0 deletions udata/core/dataservices/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from udata.core.dataset.models import Dataset
from udata.core.followers.api import FollowAPI
from udata.core.legal.mails import add_send_legal_notice_argument, send_legal_notice_on_deletion
from udata.core.organization.assignment import auto_assign_if_partial_editor
from udata.frontend.markdown import md
from udata.i18n import gettext as _
from udata.rdf import RDF_EXTENSIONS, graph_response, negociate_content
Expand Down Expand Up @@ -52,6 +53,7 @@ def post(self):
if dataservice.access_type != AccessType.RESTRICTED:
dataservice.access_audiences = []
dataservice.save()
auto_assign_if_partial_editor(dataservice)
return dataservice, 201


Expand Down
3 changes: 3 additions & 0 deletions udata/core/dataservices/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from udata.core.badges import tasks as badge_tasks
from udata.core.constants import HVD
from udata.core.dataservices.models import Dataservice
from udata.core.organization.assignment import Assignment
from udata.core.organization.constants import CERTIFIED, PUBLIC_SERVICE
from udata.core.organization.models import Organization
from udata.core.pages.models import Page
Expand Down Expand Up @@ -34,6 +35,8 @@ def purge_dataservices(self):
Activity.objects(related_to=dataservice).delete()
# Remove associated Transfers
Transfer.objects(subject=dataservice).delete()
# Remove assignments
Assignment.objects(subject=dataservice).delete()
# Remove dataservices references in Topics
TopicElement.objects(element=dataservice).update(element=None)
# Remove dataservices in pages (mongoengine doesn't support updating a field in a generic embed)
Expand Down
2 changes: 2 additions & 0 deletions udata/core/dataset/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from udata.core.followers.api import FollowAPI
from udata.core.followers.models import Follow
from udata.core.legal.mails import add_send_legal_notice_argument, send_legal_notice_on_deletion
from udata.core.organization.assignment import auto_assign_if_partial_editor
from udata.core.organization.models import Organization
from udata.core.reuse.models import Reuse
from udata.core.storages.api import handle_upload, upload_parser
Expand Down Expand Up @@ -324,6 +325,7 @@ def post(self):
"""Create a new dataset"""
form = api.validate(DatasetForm)
dataset = form.save()
auto_assign_if_partial_editor(dataset)
return dataset, 201


Expand Down
6 changes: 5 additions & 1 deletion udata/core/dataset/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

from udata.auth import Permission, UserNeed
from udata.core.organization.permissions import (
AssignmentNeed,
OrganizationAdminNeed,
OrganizationEditorNeed,
OrganizationPartialEditorNeed,
)

from .models import Resource
Expand All @@ -19,6 +21,7 @@ def __init__(self, obj):
if obj.organization:
needs.append(OrganizationAdminNeed(obj.organization.id))
needs.append(OrganizationEditorNeed(obj.organization.id))
needs.append(AssignmentNeed(obj.__class__.__name__, obj.id))
elif obj.owner:
needs.append(UserNeed(obj.owner.fs_uniquifier))

Expand All @@ -29,7 +32,7 @@ class OwnableReadPermission(BasePermission):
"""Permission to read a private ownable object.

Always grants access if the object is not private.
For private objects, requires owner, org member, or sysadmin.
For private objects, requires owner, org member (any role), or sysadmin.

We inherit from BasePermission instead of udata's Permission because
Permission automatically adds RoleNeed("admin") to all needs. This means
Expand All @@ -47,6 +50,7 @@ def __init__(self, obj):
if obj.organization:
needs.append(OrganizationAdminNeed(obj.organization.id))
needs.append(OrganizationEditorNeed(obj.organization.id))
needs.append(OrganizationPartialEditorNeed(obj.organization.id))
elif obj.owner:
needs.append(UserNeed(obj.owner.fs_uniquifier))

Expand Down
3 changes: 3 additions & 0 deletions udata/core/dataset/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from udata.core.constants import HVD
from udata.core.dataservices.models import Dataservice
from udata.core.dataset.constants import INSPIRE
from udata.core.organization.assignment import Assignment
from udata.core.organization.constants import CERTIFIED, PUBLIC_SERVICE
from udata.core.organization.models import Organization
from udata.core.pages.models import Page
Expand Down Expand Up @@ -68,6 +69,8 @@ def purge_datasets(self):
)
# Remove associated Transfers
Transfer.objects(subject=dataset).delete()
# Remove assignments
Assignment.objects(subject=dataset).delete()
# Remove each dataset's resource's file
storage = storages.resources
for resource in dataset.resources:
Expand Down
108 changes: 107 additions & 1 deletion udata/core/organization/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
uploaded_image_fields,
)
from udata.models import ContactPoint
from udata.mongo import db
from udata.mongo.errors import FieldValidationError
from udata.rdf import RDF_EXTENSIONS, graph_response, negociate_content

Expand All @@ -42,7 +43,8 @@
refuse_membership_fields,
request_fields,
)
from .constants import DEFAULT_ROLE, ORG_ROLES
from .assignment import Assignment
from .constants import ASSIGNABLE_OBJECT_TYPES, DEFAULT_ROLE, ORG_ROLES
from .forms import (
MemberForm,
MembershipInviteForm,
Expand Down Expand Up @@ -564,6 +566,38 @@ def post(self, org):
field="email", message="An invitation is already pending for this email"
)

# Resolve assignments for partial_editor invitations
raw_assignments = request.json.get("assignments", []) or []
assignment_subjects = []
if raw_assignments:
if role != "partial_editor":
raise FieldValidationError(
field="assignments",
message="Assignments can only be set for partial_editor role",
)
allowed_classes = ASSIGNABLE_OBJECT_TYPES
for raw in raw_assignments:
cls_name = raw.get("class")
obj_id = raw.get("id")
if cls_name not in allowed_classes:
raise FieldValidationError(
field="assignments",
message=f"Invalid object class '{cls_name}'",
)
model_cls = db.resolve_model(cls_name)
obj = model_cls.objects(id=obj_id).first()
if not obj:
raise FieldValidationError(
field="assignments",
message=f"{cls_name} '{obj_id}' not found",
)
if not hasattr(obj, "organization") or obj.organization != org:
raise FieldValidationError(
field="assignments",
message=f"{cls_name} '{obj_id}' does not belong to this organization",
)
assignment_subjects.append(obj)

# Create invitation
invitation = MembershipRequest(
kind="invitation",
Expand All @@ -572,6 +606,7 @@ def post(self, org):
created_by=current_user._get_current_object(),
role=role,
comment=comment,
assignments=assignment_subjects,
)
org.requests.append(invitation)
org.save()
Expand All @@ -592,10 +627,14 @@ def put(self, org, user):
"""Update member status into a given organization."""
org.permissions["members"].test()
member = org.member(user)
old_role = member.role
form = api.validate(MemberForm, member)
form.populate_obj(member)
org.save()

if old_role == "partial_editor" and member.role != "partial_editor":
Assignment.objects(user=user, organization=org).delete()

return member

@api.secure
Expand All @@ -606,13 +645,80 @@ def delete(self, org, user):
member = org.member(user)
if member:
Organization.objects(id=org.id).update_one(pull__members=member)
Assignment.objects(user=user, organization=org).delete()
org.reload()
org.count_members()
return "", 204
else:
api.abort(404)


@ns.route("/<org:org>/assignments/", endpoint="organization_assignments", doc=common_doc)
class AssignmentListAPI(API):
@api.secure
@api.doc("list_organization_assignments")
@api.marshal_list_with(Assignment.__read_fields__)
def get(self, org):
"""List assignments for this organization"""
org.permissions["members"].test()
return list(Assignment.objects(organization=org))


@ns.route(
"/<org:org>/member/<user:user>/assignments/",
endpoint="member_assignments",
doc=common_doc,
)
class MemberAssignmentsAPI(API):
@api.secure
@api.doc("sync_member_assignments", responses={403: "Not Authorized"})
@api.marshal_list_with(Assignment.__read_fields__)
def put(self, org, user):
"""Sync assignments for a partial_editor member.

Replaces all current assignments with the provided list.
"""
org.permissions["members"].test()

member = org.member(user)
if not member or member.role != "partial_editor":
api.abort(400, "User must be a partial_editor member of this organization")

raw_subjects = request.json or []
allowed_classes = ASSIGNABLE_OBJECT_TYPES

desired_subjects = []
for raw in raw_subjects:
cls_name = raw.get("class")
obj_id = raw.get("id")
if cls_name not in allowed_classes:
api.abort(400, f"Invalid object class '{cls_name}'")
model_cls = db.resolve_model(cls_name)
obj = model_cls.objects(id=obj_id).first()
if not obj:
api.abort(400, f"{cls_name} '{obj_id}' not found")
if not hasattr(obj, "organization") or obj.organization != org:
api.abort(400, f"{cls_name} '{obj_id}' does not belong to this organization")
desired_subjects.append(obj)

current = list(Assignment.objects(user=user, organization=org))
current_by_subject = {(a.subject.__class__.__name__, str(a.subject.id)): a for a in current}
desired_keys = {(s.__class__.__name__, str(s.id)) for s in desired_subjects}

# Delete removed
for key, assignment in current_by_subject.items():
if key not in desired_keys:
assignment.delete()

# Create new
for subject in desired_subjects:
key = (subject.__class__.__name__, str(subject.id))
if key not in current_by_subject:
Assignment(user=user, organization=org, subject=subject).save()

return list(Assignment.objects(user=user, organization=org))


@ns.route("/<id>/followers/", endpoint="organization_followers")
@ns.doc(
get={"id": "list_organization_followers"},
Expand Down
20 changes: 20 additions & 0 deletions udata/core/organization/api_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@

from .constants import BIGGEST_LOGO_SIZE, DEFAULT_ROLE, MEMBERSHIP_STATUS, ORG_ROLES, REQUEST_TYPES

generic_reference_fields = api.model(
"GenericReference",
{
"class": fields.String(attribute=lambda o: o.__class__.__name__),
"id": fields.String(attribute=lambda o: str(o.id)),
},
)

org_permissions_fields = api.model(
"OrganizationPermissions",
{
Expand Down Expand Up @@ -119,6 +127,10 @@ def member_email_with_visibility_check(email):
description="The role to assign", enum=list(ORG_ROLES), default=DEFAULT_ROLE
),
"comment": fields.String(description="A request comment from the user"),
"assignments": fields.List(
fields.Nested(generic_reference_fields),
description="Objects to assign on acceptance (for partial_editor invitations)",
),
},
)

Expand All @@ -131,6 +143,10 @@ def member_email_with_visibility_check(email):
description="The role to assign", enum=list(ORG_ROLES), default=DEFAULT_ROLE
),
"comment": fields.String(description="Invitation message"),
"assignments": fields.List(
fields.Nested(generic_reference_fields),
description="Objects to assign on acceptance (for partial_editor invitations)",
),
},
)

Expand All @@ -144,6 +160,10 @@ def member_email_with_visibility_check(email):
),
"comment": fields.String(description="Invitation message"),
"created": fields.ISODateTime(description="The invitation creation date", readonly=True),
"assignments": fields.List(
fields.Nested(generic_reference_fields),
description="Objects to assign on acceptance (for partial_editor invitations)",
),
},
)

Expand Down
54 changes: 54 additions & 0 deletions udata/core/organization/assignment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from datetime import datetime

from mongoengine import CASCADE

from udata.api_fields import field, generate_fields
from udata.core.owned import Owned
from udata.core.user.api_fields import user_ref_fields
from udata.mongo import db

from .constants import ASSIGNABLE_OBJECT_TYPES


@generate_fields()
class Assignment(db.Document):
user = field(
db.ReferenceField("User", required=True, reverse_delete_rule=CASCADE),
nested_fields=user_ref_fields,
)
organization = field(
db.ReferenceField("Organization", required=True),
readonly=True,
)
subject = field(
db.GenericReferenceField(choices=ASSIGNABLE_OBJECT_TYPES, required=True),
)
created_at = field(db.DateTimeField(default=datetime.utcnow), readonly=True)

meta = {
"indexes": [
{"fields": ["user", "organization"]},
{"fields": ["user", "subject"], "unique": True},
],
}


def auto_assign_if_partial_editor(obj):
"""Auto-assign an object to the current user if they are a partial editor."""
from udata.auth import current_user

if not obj.organization or not current_user.is_authenticated:
return
member = obj.organization.member(current_user._get_current_object())
if member and member.role == "partial_editor":
Assignment(
user=current_user._get_current_object(),
organization=obj.organization,
subject=obj,
).save()


@Owned.on_owner_change.connect
def clean_assignments_on_owner_change(document, previous):
"""Remove all assignments for an object when its ownership changes (e.g. transfer)."""
Assignment.objects(subject=document).delete()
3 changes: 3 additions & 0 deletions udata/core/organization/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
ORG_ROLES = {
"admin": _("Administrator"),
"editor": _("Editor"),
"partial_editor": _("Partial editor"),
}
DEFAULT_ROLE = "editor"

Expand Down Expand Up @@ -44,6 +45,8 @@
)


ASSIGNABLE_OBJECT_TYPES = {"Dataset", "Dataservice", "Reuse"}

TITLE_SIZE_LIMIT = 350
DESCRIPTION_SIZE_LIMIT = 100000

Expand Down
Loading