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

feat: Project logs audit logs #810

Merged
merged 6 commits into from
Sep 15, 2023
Merged
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
6 changes: 6 additions & 0 deletions terraso_backend/apps/graphql/schema/memberships.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from apps.graphql.exceptions import GraphQLNotAllowedException, GraphQLNotFoundException
from apps.notifications.email import EmailNotification

from ..signals import membership_added_signal, membership_updated_signal
from .commons import BaseAuthenticatedMutation, BaseDeleteMutation, TerrasoConnection
from .constants import MutationTypes

Expand Down Expand Up @@ -75,6 +76,7 @@ class Input:

@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
request_user = info.context.user
user_email = kwargs.pop("user_email")
group_slug = kwargs.pop("group_slug")

Expand Down Expand Up @@ -118,6 +120,8 @@ def mutate_and_get_payload(cls, root, info, **kwargs):
membership.user_role = user_role
membership.save()

membership_added_signal.send(sender=cls, membership=membership, user=request_user)

return cls(membership=membership)


Expand Down Expand Up @@ -181,6 +185,8 @@ def mutate_and_get_payload(cls, root, info, **kwargs):

membership.save()

membership_updated_signal.send(sender=cls, membership=membership, user=user)

return cls(membership=membership)


Expand Down
22 changes: 20 additions & 2 deletions terraso_backend/apps/graphql/schema/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ def mutate_and_get_payload(cls, root, info, **kwargs):
if not client_time:
client_time = datetime.now()
action = log_api.CREATE
metadata = {"name": kwargs["name"], "privacy": kwargs["privacy"]}
metadata = {
"name": kwargs["name"],
"privacy": kwargs["privacy"],
"description": kwargs["description"] if "description" in kwargs else None,
}
logger.log(
user=user,
action=action,
Expand Down Expand Up @@ -159,10 +163,24 @@ class Input:
@classmethod
@transaction.atomic
def mutate_and_get_payload(cls, root, info, **kwargs):
logger = cls.get_logger()
user = info.context.user
project_id = kwargs["id"]
project = cls.get_or_throw(Project, "id", project_id)
if not user.has_perm(Project.get_perm("change"), project):
cls.not_allowed()
kwargs["privacy"] = kwargs["privacy"].value
return super().mutate_and_get_payload(root, info, **kwargs)
result = super().mutate_and_get_payload(root, info, **kwargs)
metadata = {
"name": kwargs["name"],
"privacy": kwargs["privacy"],
"description": kwargs["description"] if "description" in kwargs else None,
}
logger.log(
user=user,
action=log_api.CHANGE,
resource=result.project,
client_time=datetime.now(),
metadata=metadata,
)
return result
2 changes: 1 addition & 1 deletion terraso_backend/apps/graphql/schema/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs):
continue
metadata[key] = value
if project_id:
metadata["project_name"] = project.name
metadata["project_id"] = str(project.id)

log.log(
user=user,
Expand Down
19 changes: 19 additions & 0 deletions terraso_backend/apps/graphql/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright © 2023 Technology Matters
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see https://www.gnu.org/licenses/.

from django.dispatch import Signal

membership_added_signal = Signal()
membership_updated_signal = Signal()
3 changes: 3 additions & 0 deletions terraso_backend/apps/project_management/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@
class ProjectManagementConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.project_management"

def ready(self):
from .signals import handle_membership_added, handle_membership_updated # noqa
62 changes: 62 additions & 0 deletions terraso_backend/apps/project_management/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Copyright © 2023 Technology Matters
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see https://www.gnu.org/licenses/.

from datetime import datetime

from django.dispatch import receiver

from apps.audit_logs import api as audit_log_api
from apps.audit_logs import services
from apps.graphql.signals import membership_added_signal, membership_updated_signal

audit_logger = services.new_audit_logger()


def _handle_membership_log(user, action, membership, client_time):
try:
project = membership.group.project
except Exception:
# No project for membership, do nothing
return

audit_logger.log(
user=user,
action=action,
resource=membership,
metadata={
"user_email": membership.user.email,
"user_role": membership.user_role,
"project_id": str(project.id),
},
client_time=client_time,
)


@receiver(membership_added_signal)
def handle_membership_added(sender, **kwargs):
membership = kwargs["membership"]
user = kwargs["user"]
client_time = datetime.now()

_handle_membership_log(user, audit_log_api.CREATE, membership, client_time)


@receiver(membership_updated_signal)
def handle_membership_updated(sender, **kwargs):
membership = kwargs["membership"]
user = kwargs["user"]
client_time = datetime.now()

_handle_membership_log(user, audit_log_api.CHANGE, membership, client_time)
130 changes: 120 additions & 10 deletions terraso_backend/tests/graphql/mutations/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from graphene_django.utils.testing import graphql_query
from mixer.backend.django import mixer

from apps.audit_logs.api import CREATE
from apps.audit_logs.api import CHANGE, CREATE
from apps.audit_logs.models import Log
from apps.core.models.users import User
from apps.project_management.models import Project
Expand Down Expand Up @@ -42,16 +42,90 @@ def test_create_project(client, user):
log_result = logs[0]
assert log_result.event == CREATE.value
assert log_result.resource_object == project
expected_metadata = {"name": "testProject", "privacy": "private"}
expected_metadata = {
"name": "testProject",
"privacy": "private",
"description": "A test project",
}
assert log_result.metadata == expected_metadata


ADD_MEMBERSHIP_GRAPHQL = """
mutation addUserToProject($input: MembershipAddMutationInput!) {
addMembership(input: $input) {
membership {
id
}
}
}
"""


def test_add_user_to_project(client, project, project_manager, user):
client.force_login(project_manager)
response = graphql_query(
ADD_MEMBERSHIP_GRAPHQL,
variables={
"input": {
"userEmail": user.email,
"groupSlug": project.group.slug,
"userRole": "member",
}
},
client=client,
)
content = json.loads(response.content)
assert "errors" not in content and "errors" not in content["data"]["addMembership"]
assert project.is_member(user)


def test_add_user_to_project_audit_log(client, project, project_manager, user):
client.force_login(project_manager)

assert project_manager.id != user.id

response = graphql_query(
ADD_MEMBERSHIP_GRAPHQL,
variables={
"input": {
"userEmail": user.email,
"groupSlug": project.group.slug,
"userRole": "member",
}
},
client=client,
)

assert response.status_code == 200

membership = project.group.memberships.filter(user=user).first()

logs = Log.objects.all()
assert len(logs) == 1
log_result = logs[0]
assert log_result.event == CREATE.value
assert log_result.user_human_readable == project_manager.full_name()
assert log_result.resource_object == membership
expected_metadata = {
"user_email": user.email,
"user_role": "member",
"project_id": str(project.id),
}
assert log_result.metadata == expected_metadata


def test_update_user_to_project_audit_log(client, project, project_manager, user):
client.force_login(project_manager)

assert project_manager.id != user.id

project.group.add_member(user)
membership = project.group.memberships.filter(user=user).first()

response = graphql_query(
"""
mutation addUserToProject($input: MembershipAddMutationInput!) {
addMembership(input: $input) {
mutation updateMembership($input: MembershipUpdateMutationInput!) {
updateMembership(input: $input) {
membership {
id
}
Expand All @@ -60,16 +134,29 @@ def test_add_user_to_project(client, project, project_manager, user):
""",
variables={
"input": {
"userEmail": user.email,
"groupSlug": project.group.slug,
"userRole": "member",
"id": str(membership.id),
"userRole": "manager",
}
},
client=client,
)
content = json.loads(response.content)
assert "errors" not in content and "errors" not in content["data"]["addMembership"]
assert project.is_member(user)

assert response.status_code == 200

membership = project.group.memberships.filter(user=user).first()

logs = Log.objects.all()
assert len(logs) == 1
log_result = logs[0]
assert log_result.event == CHANGE.value
assert log_result.user_human_readable == project_manager.full_name()
assert log_result.resource_object == membership
expected_metadata = {
"user_email": user.email,
"user_role": "manager",
"project_id": str(project.id),
}
assert log_result.metadata == expected_metadata


DELETE_PROJECT_GRAPHQL = """
Expand Down Expand Up @@ -185,6 +272,29 @@ def test_update_project_user_is_manager(project, client, project_manager):
assert content["data"]["updateProject"]["project"]["privacy"] == "PRIVATE"


def test_update_project_audit_log(project, client, project_manager):
input = {
"id": str(project.id),
"name": "test_name",
"privacy": "PRIVATE",
"description": "A test project",
}
client.force_login(project_manager)

response = graphql_query(UPDATE_PROJECT_GRAPHQL, input_data=input, client=client)

assert response.status_code == 200

logs = Log.objects.all()
assert len(logs) == 1
log_result = logs[0]
assert log_result.event == CHANGE.value
assert log_result.user_human_readable == project_manager.full_name()
assert log_result.resource_object == project
expected_metadata = {"name": "test_name", "privacy": "private", "description": "A test project"}
assert log_result.metadata == expected_metadata


def test_update_project_user_not_manager(project, client):
user = mixer.blend(User)
project.add_member(user)
Expand Down
2 changes: 1 addition & 1 deletion terraso_backend/tests/graphql/mutations/test_sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def test_update_site_in_project(client, project, project_manager, site):
log_result = logs[0]
assert log_result.event == CHANGE.value
assert log_result.resource_object == site
assert log_result.metadata["project_name"] == project.name
assert log_result.metadata["project_id"] == str(project.id)


def test_adding_site_to_project_user_not_manager(client, project, site, user):
Expand Down