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

Organization member-management #1414

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c6cf298
change hr_group and primary_group to admin_group and member_group in …
simensandhaug Nov 7, 2022
d800ba4
update group constants
simensandhaug Nov 7, 2022
5faef25
change instances of hr_group and primary_group
simensandhaug Nov 7, 2022
1d6e42b
fix query error
simensandhaug Nov 7, 2022
aa5c147
write custom migration
simensandhaug Nov 14, 2022
ad93482
revert changes to previous migrations
simensandhaug Nov 17, 2022
22a5b20
revert changes to previous migrations
simensandhaug Nov 17, 2022
1eef979
revert changes to previous migrations
simensandhaug Nov 17, 2022
de5d3d7
move custom migrations to more appropriate folder
simensandhaug Nov 17, 2022
3733a79
merge migrations due to conflicts
simensandhaug Nov 17, 2022
ccd26d8
remove manage_organization permission from organizations/signals.py
simensandhaug Nov 21, 2022
bb000da
uncomment manage_organization permission in organizations/models.py
simensandhaug Nov 21, 2022
008dfdb
remove autogenerated yarn.lock file
simensandhaug Nov 21, 2022
188b95f
quality of life improvement for admin-panel, more easily readable wit…
simensandhaug Nov 21, 2022
2cde7d2
Admin-panel improvements and update signal and migration to be more d…
simensandhaug Nov 21, 2022
30d6ad4
update migration
simensandhaug Nov 22, 2022
fe30038
fix naming error
simensandhaug Nov 22, 2022
6556ecf
fix typing error
simensandhaug Nov 22, 2022
1dcf3d8
commit before merge
simensandhaug Jan 12, 2023
211c9fe
merge with feat/migrate-org-groups
simensandhaug Jan 12, 2023
aacf92b
start on mutations
simensandhaug Jan 12, 2023
1415d84
add removeMembership mutation
simensandhaug Jan 30, 2023
dfc97e5
Merge branch 'main' into 1360-admins-can-add-delete-promote-and-demot…
simensandhaug Jan 30, 2023
6238bd4
fix unused desctructured variables
simensandhaug Jan 30, 2023
2b43178
fix graphql schema
simensandhaug Jan 30, 2023
ca93d1a
Change role and delete membership fixed
simensandhaug Jan 30, 2023
1e2b663
add membership_with_username mutation
simensandhaug Feb 2, 2023
8a16781
add comments
simensandhaug Feb 2, 2023
2f06296
fix black linting
simensandhaug Feb 13, 2023
3af4c03
Merge branch 'main' into 1360-admins-can-add-delete-promote-and-demot…
MagnusHafstad Sep 28, 2023
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 backend/apps/ecommerce/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def setUp(self) -> None:
MembershipFactory(
user=self.staff_user,
organization=self.organization,
group=self.organization.primary_group,
group=self.organization.member_group,
)
self.total_quantity = 5
self.max_buyable_quantity = 2
Expand Down
2 changes: 1 addition & 1 deletion backend/apps/forms/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
def handle_new_form(sender, instance: Form, created: bool, **kwargs) -> None:
if created:
perms = ["forms.manage_form", "forms.change_form", "forms.delete_form"]
group = instance.organization.hr_group.group
group = instance.organization.admin_group.group
for perm in perms:
assign_perm(perm, group, instance)
2 changes: 1 addition & 1 deletion backend/apps/forms/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def setUp(self) -> None:
MembershipFactory(
user=self.authorized_user,
organization=self.organization,
group=self.organization.hr_group,
group=self.organization.admin_group,
)

# Create the form
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def set_primary_groups(apps, schema_editor):
created = True
if organization.hr_group is None:
hr_group = ResponsibleGroup.objects.create(
name="HR", description=f"HR-gruppen til {organization.name}. Tillatelser for å se og behandle søknader."
name="HR",
description=f"HR-gruppen til {organization.name}. Tillatelser for å se og behandle søknader.",
)
organization.hr_group = hr_group
created = True
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 3.2.16 on 2023-01-23 18:47
# Generated by Django 3.2.16 on 2022-11-21 20:19

from django.db import migrations

Expand Down
13 changes: 7 additions & 6 deletions backend/apps/organizations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.db import models
from django.db.models import UniqueConstraint

from apps.permissions.constants import HR_TYPE, PRIMARY_TYPE
from apps.permissions.constants import ADMIN_GROUP_TYPE, MEMBER_GROUP_TYPE
from apps.permissions.models import ResponsibleGroup


Expand All @@ -28,7 +28,8 @@ class Organization(models.Model):
# Permission groups
# All members are added to the primary group
# Members can be added to groups programatically
# The HR-group has the "forms.manage_form" permission, allowing them to view and manage responses to e.g. listings.
# The ADMIN-group has the "forms.manage_form" permission, allowing them to view and manage responses to e.g.
# listings.
# The primary group is intended to act as a group for organizations who need any kind of
# special permission, e.g. hyttestyret
# Or if we wish to limit the creation of events or listings to certain organizations.
Expand All @@ -42,16 +43,16 @@ class Organization(models.Model):
)

@property
def hr_group(self) -> Optional["ResponsibleGroup"]:
def admin_group(self) -> Optional["ResponsibleGroup"]:
try:
return self.permission_groups.get(group_type=HR_TYPE)
return self.permission_groups.get(group_type=ADMIN_GROUP_TYPE)
except ResponsibleGroup.DoesNotExist:
return None

@property
def primary_group(self) -> Optional["ResponsibleGroup"]:
def member_group(self) -> Optional["ResponsibleGroup"]:
try:
return self.permission_groups.get(group_type=PRIMARY_TYPE)
return self.permission_groups.get(group_type=MEMBER_GROUP_TYPE)
except ResponsibleGroup.DoesNotExist:
return None

Expand Down
79 changes: 72 additions & 7 deletions backend/apps/organizations/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
from django.utils.text import slugify
from decorators import permission_required

from apps.users.types import UserType
from apps.permissions.models import ResponsibleGroup

from apps.organizations import permissions as perms
from apps.organizations.models import Membership, Organization
from apps.organizations.types import MembershipType, OrganizationType
from apps.users.models import User


def get_organization_from_data(*_, membership_data, **kwargs) -> Organization:
Expand Down Expand Up @@ -47,7 +47,7 @@
id = graphene.ID(required=True)
organization_data = OrganizationInput(required=False)

@permission_required("organizations.manage_organization")
@permission_required("organizations.change_organization")
def mutate(self, info, id, organization_data=None):
organization = Organization.objects.get(pk=id)
user = info.context.user
Expand Down Expand Up @@ -111,12 +111,77 @@
return AssignMembership(membership=membership, ok=True)


class RemoveMembership(graphene.Mutation):
removed_member = graphene.Field(UserType)
class MembershipInputWithUsername(graphene.InputObjectType):
username = graphene.String()
organization_id = graphene.ID()
group_id = graphene.ID()


class AssignMembershipWithUsername(graphene.Mutation):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to do a longer write up here with a general suggestion, so be warned: wall of text incoming.

Early on in the life span of this project, I got carried away with permissions, thinking it would be cool to have a super-flexible permission structure that could handle as many use cases as I could imagine. This resulted in us adding the complex permission structure of the project today, which are way too complex. In reality, we just need, at most, a way to tell if a user is and ADMIN or a MEMBER in an organization.

As a result, my suggestion here is to try to hide some of the complexity of our permission structure, to prevent it from leaking out through the API, and make it easier to work with in the future. It doesn't require a huge structural change, we can just change this API slightly.

Rather than forcing the users of the API (us) to have to figure out which group_id a user should be added to, we can hide that complexity behind a choice with two options: A user should either be added as an ADMIN or as a MEMBER.

Specifically, something like this:

class MembershipType(graphene.Enum):
    MEMBER = "MEMBER"
    ADMIN = "ADMIN"

class AssignMembershipWithUsernameInput(graphene.InputObjectType):
    username = graphene.String(required=True)
    organization_id = graphene.ID(required=True)
    membership_type = MembershipType # Not 100% on the syntax here, this is my best guess.


class AssignMembershipWithUsername(graphene.Mutation):
    membership = graphene.Field(MembershipType)
    # If you want to avoid having to deal with cache mutations frontend, you can also add this
    organization = graphene.Field(OrganizationType)

    @permission_required("organizations.manage_organization", fn=get_organization_from_data)
    def mutate(self, _, membership_data):
        # Implement it similar to today, but instead of getting `group_id` directly, do something along the lines of
        if membership_data["membership_type"] == MembershipType.ADMIN:
             group = organization.admin_group
        elif membership_data["membership_type"== MembershipType.MEMBER:
            group = organization.member_group 
       # followed by more or less the same logic as before 

The overall goal here is to reduce the complexity for the end-user of the API, placing more of the responsibility on the backend to handle the underlying (and in this case, unnecessary 😢 ) complexity.

membership = graphene.Field(MembershipType)
ok = graphene.Boolean()

class Arguments:
member_id = graphene.ID()
membership_data = MembershipInputWithUsername(required=True)

@permission_required("organizations.manage_organization", fn=get_organization_from_data)
def mutate(self, _, membership_data):
organization = Organization.objects.prefetch_related("permission_groups").get(

Check warning on line 129 in backend/apps/organizations/mutations.py

View check run for this annotation

Codecov / codecov/patch

backend/apps/organizations/mutations.py#L129

Added line #L129 was not covered by tests
pk=membership_data["organization_id"]
)

try:
group = organization.permission_groups.get(pk=membership_data.get("group_id"))
except ResponsibleGroup.DoesNotExist:
return AssignMembershipWithUsername(membership=None, ok=False)

Check warning on line 136 in backend/apps/organizations/mutations.py

View check run for this annotation

Codecov / codecov/patch

backend/apps/organizations/mutations.py#L133-L136

Added lines #L133 - L136 were not covered by tests

try:
user_id = User.objects.get(username=membership_data["username"]).id
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the comment about moving this to the backend

Suggested change
user_id = User.objects.get(username=membership_data["username"]).id
user_id = User.objects.get(username=membership_data["username"].lower()).id

except User.DoesNotExist:
return AssignMembershipWithUsername(membership=None, ok=False)

Check warning on line 141 in backend/apps/organizations/mutations.py

View check run for this annotation

Codecov / codecov/patch

backend/apps/organizations/mutations.py#L138-L141

Added lines #L138 - L141 were not covered by tests

membership = Membership(

Check warning on line 143 in backend/apps/organizations/mutations.py

View check run for this annotation

Codecov / codecov/patch

backend/apps/organizations/mutations.py#L143

Added line #L143 was not covered by tests
organization_id=membership_data["organization_id"],
user_id=user_id,
group=group,
)
membership.save()
return AssignMembershipWithUsername(membership=membership, ok=True)

Check warning on line 149 in backend/apps/organizations/mutations.py

View check run for this annotation

Codecov / codecov/patch

backend/apps/organizations/mutations.py#L148-L149

Added lines #L148 - L149 were not covered by tests

def mutate(self, info, member_id):
raise NotImplementedError("Denne funksjonaliteten er ikke implementert.")

class DeleteMembership(graphene.Mutation):
membership = graphene.Field(MembershipType)
ok = graphene.Boolean()

class Arguments:
membership_id = graphene.ID()

@permission_required("organizations.manage_organization")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, some poor decision making on my part to add a lot of complexity with permissions comes back to bite us 🦷 This permission check must include the ID of the organization the membership belongs to. Otherwise, you can delete memberships from other organizations than your own.

Look to get_organization_from_data for inspiration.

def mutate(self, info, membership_id):
membership = Membership.objects.get(pk=membership_id)
membership.delete()

Check warning on line 162 in backend/apps/organizations/mutations.py

View check run for this annotation

Codecov / codecov/patch

backend/apps/organizations/mutations.py#L161-L162

Added lines #L161 - L162 were not covered by tests

ok = True
return DeleteMembership(ok=ok)

Check warning on line 165 in backend/apps/organizations/mutations.py

View check run for this annotation

Codecov / codecov/patch

backend/apps/organizations/mutations.py#L164-L165

Added lines #L164 - L165 were not covered by tests


class ChangeMembershipInput(graphene.InputObjectType):
membership_id = graphene.ID()
group_id = graphene.ID()


class ChangeMembership(graphene.Mutation):
membership = graphene.Field(MembershipType)
ok = graphene.Boolean()

class Arguments:
membership_data = ChangeMembershipInput(required=True)

@permission_required("organizations.manage_organization")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, the permission check must include the organization ID

def mutate(self, info, membership_data):
membership = Membership.objects.get(pk=membership_data["membership_id"])
membership.group_id = membership_data["group_id"]
membership.save()

Check warning on line 184 in backend/apps/organizations/mutations.py

View check run for this annotation

Codecov / codecov/patch

backend/apps/organizations/mutations.py#L182-L184

Added lines #L182 - L184 were not covered by tests

ok = True
return ChangeMembership(membership=membership, ok=ok)

Check warning on line 187 in backend/apps/organizations/mutations.py

View check run for this annotation

Codecov / codecov/patch

backend/apps/organizations/mutations.py#L186-L187

Added lines #L186 - L187 were not covered by tests
6 changes: 6 additions & 0 deletions backend/apps/organizations/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@

from .mutations import (
AssignMembership,
AssignMembershipWithUsername,
DeleteMembership,
CreateOrganization,
DeleteOrganization,
UpdateOrganization,
ChangeMembership,
)
from .resolvers import MembershipResolvers, OrganizationResolvers
from .types import MembershipType, OrganizationType
Expand All @@ -17,6 +20,9 @@ class OrganizationMutations(graphene.ObjectType):
delete_organization = DeleteOrganization.Field()

assign_membership = AssignMembership.Field()
delete_membership = DeleteMembership.Field()
change_membership = ChangeMembership.Field()
assign_membership_with_username = AssignMembershipWithUsername.Field()


class OrganizationQueries(graphene.ObjectType, OrganizationResolvers, MembershipResolvers):
Expand Down
30 changes: 16 additions & 14 deletions backend/apps/organizations/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@

from apps.organizations.models import Membership, Organization
from apps.permissions.constants import (
HR_GROUP_NAME,
HR_TYPE,
ADMIN_GROUP_NAME,
ADMIN_GROUP_TYPE,
ORGANIZATION,
PRIMARY_GROUP_NAME,
PRIMARY_TYPE,
MEMBER_GROUP_NAME,
MEMBER_GROUP_TYPE,
)
from apps.permissions.models import ResponsibleGroup


@receiver(post_save, sender=Membership)
def handle_new_member(instance: Membership, **kwargs):
optional_group: Optional[ResponsibleGroup] = instance.group
group: Group = instance.organization.primary_group.group
group: Group = instance.organization.member_group.group
org_group: Group = Group.objects.get(name=ORGANIZATION)
user = instance.user
user.groups.add(org_group)
Expand All @@ -31,7 +31,7 @@

@receiver(pre_delete, sender=Membership)
def handle_removed_member(instance: Membership, **kwargs):
group: Group = instance.organization.primary_group.group
group: Group = instance.organization.member_group.group

Check warning on line 34 in backend/apps/organizations/signals.py

View check run for this annotation

Codecov / codecov/patch

backend/apps/organizations/signals.py#L34

Added line #L34 was not covered by tests
org_group: Group = Group.objects.get(name=ORGANIZATION)
user = instance.user
if group:
Expand All @@ -44,19 +44,21 @@
@receiver(post_save, sender=Organization)
def create_default_groups(instance: Organization, created, **kwargs):
"""
Creates and assigns a primary group and HR group to members of the organization.
Creates and assigns a primary group and ADMIN group to members of the organization.
"""
if created:
ResponsibleGroup.objects.create(
name=PRIMARY_GROUP_NAME,
name=f"{instance.name}:{MEMBER_GROUP_NAME}",
description=f"Medlemmer av {instance.name}.",
organization=instance,
group_type=PRIMARY_TYPE,
group_type=MEMBER_GROUP_TYPE,
)
hr_group = ResponsibleGroup.objects.create(
name=HR_GROUP_NAME,
description=f"HR-gruppen til {instance.name}. Tillatelser for å se og behandle søknader.",
admin_group = ResponsibleGroup.objects.create(
name=f"{instance.name}:{ADMIN_GROUP_NAME}",
description=f"ADMIN-gruppen til {instance.name}. Tillatelser for å se og behandle søknader og medlemmer.",
organization=instance,
group_type=HR_TYPE,
group_type=ADMIN_GROUP_TYPE,
)
assign_perm("forms.add_form", hr_group.group)
print(admin_group.group, instance)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
print(admin_group.group, instance)

assign_perm("forms.add_form", admin_group.group)
# assign_perm("organizations.manage_organization", admin_group.group, instance)
8 changes: 4 additions & 4 deletions backend/apps/organizations/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
class OrganizationType(DjangoObjectType):
absolute_slug = graphene.String()
listings = graphene.List(NonNull(ListingType))
primary_group = graphene.Field(source="primary_group", type=ResponsibleGroupType)
hr_group = graphene.Field(source="hr_group", type=ResponsibleGroupType)
member_group = graphene.Field(source="member_group", type=ResponsibleGroupType)
admin_group = graphene.Field(source="admin_group", type=ResponsibleGroupType)

class Meta:
model = Organization
Expand All @@ -30,8 +30,8 @@ class Meta:
"users",
"events",
"logo_url",
"primary_group",
"hr_group",
"member_group",
"admin_group",
]

@staticmethod
Expand Down
8 changes: 4 additions & 4 deletions backend/apps/permissions/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
DefaultPermissionsType = Final[list[tuple[str, str]]]

# Default ResponsibleGroup types
PRIMARY_TYPE: Literal["PRIMARY"] = "PRIMARY"
HR_TYPE: Literal["HR"] = "HR"
MEMBER_GROUP_TYPE: Literal["MEMBER"] = "MEMBER"
ADMIN_GROUP_TYPE: Literal["ADMIN"] = "ADMIN"

ORGANIZATION: Final = "Organization member"
INDOK: Final = "Indøk"
REGISTERED_USER: Final = "Registered user"
PRIMARY_GROUP_NAME: Final = "Medlem"
HR_GROUP_NAME: Final = "HR"
MEMBER_GROUP_NAME: Final = "Medlem"
ADMIN_GROUP_NAME: Final = "Administrator"

DEFAULT_ORGANIZATION_PERMISSIONS: DefaultPermissionsType = [
("events", "add_event"),
Expand Down
14 changes: 7 additions & 7 deletions backend/apps/permissions/migrations/0003_auto_20210824_1213.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from django.db import migrations
from django.db.models.query_utils import Q
from apps.permissions.constants import PRIMARY_GROUP_NAME, HR_GROUP_NAME
from apps.permissions.constants import MEMBER_GROUP_NAME, ADMIN_GROUP_NAME

if TYPE_CHECKING:
from apps.organizations import models
Expand All @@ -19,14 +19,14 @@ def improve_group_legibility(apps, _):
responsible_group: "ResponsibleGroup" = group.responsiblegroup
try:
organization: "models.Organization" = Organization.objects.get(
Q(primary_group=responsible_group) | Q(hr_group=responsible_group)
Q(member_group=responsible_group) | Q(admin_group=responsible_group)
)

responsible_group_name = responsible_group.name
if organization.primary_group == responsible_group:
responsible_group_name = PRIMARY_GROUP_NAME
elif organization.hr_group == responsible_group:
responsible_group_name = HR_GROUP_NAME
if organization.member_group == responsible_group:
responsible_group_name = MEMBER_GROUP_NAME
elif organization.admin_group == responsible_group:
responsible_group_name = ADMIN_GROUP_NAME
if responsible_group.name != responsible_group_name:
responsible_group.name = responsible_group_name
responsible_group.save()
Expand All @@ -50,7 +50,7 @@ def reverse_legible_group_names(apps, _):
responsible_group = group.responsiblegroup
try:
organization = responsible_group.organization
if organization.primary_group == responsible_group:
if organization.member_group == responsible_group:
responsible_group.name = organization.name
responsible_group.save()
except Organization.DoesNotExist:
Expand Down
Loading
Loading