From f989b76c2eda227ff1fc38901864d73ca7afa3a6 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 23 Apr 2024 00:18:35 +0200 Subject: [PATCH] CM-869: add comment mutation and query --- grievance_social_protection/apps.py | 20 +++--- grievance_social_protection/gql_mutations.py | 50 +++++++++++++-- grievance_social_protection/gql_queries.py | 51 +++++++++++++-- .../migrations/0013_auto_20240422_2108.py | 40 ++++++++++++ ...vance_social_protection_rights_to_admin.py | 51 +++++++++++++++ grievance_social_protection/models.py | 8 +-- grievance_social_protection/schema.py | 15 +++++ grievance_social_protection/services.py | 37 ++++++++++- grievance_social_protection/validations.py | 62 ++++++++++++++++++- locale/en/LC_MESSAGES/django.po | 9 +++ 10 files changed, 313 insertions(+), 30 deletions(-) create mode 100644 grievance_social_protection/migrations/0013_auto_20240422_2108.py create mode 100644 grievance_social_protection/migrations/0014_add_grievance_social_protection_rights_to_admin.py diff --git a/grievance_social_protection/apps.py b/grievance_social_protection/apps.py index 7a3c301..993b08f 100644 --- a/grievance_social_protection/apps.py +++ b/grievance_social_protection/apps.py @@ -12,14 +12,12 @@ DEFAULT_CFG = { "default_validations_disabled": False, - "gql_query_tickets_perms": ["123000"], - "gql_mutation_create_tickets_perms": ["123001"], - "gql_mutation_update_tickets_perms": ["123002"], - "gql_mutation_delete_tickets_perms": ["123003"], - "gql_query_categorys_perms": ["123004"], - "gql_mutation_create_categorys_perms": ["123005"], - "gql_mutation_update_categorys_perms": ["123006"], - "gql_mutation_delete_categorys_perms": ["123007"], + "gql_query_tickets_perms": ["127000"], + "gql_query_comments_perms": ["127004"], + "gql_mutation_create_tickets_perms": ["127001"], + "gql_mutation_update_tickets_perms": ["127002"], + "gql_mutation_delete_tickets_perms": ["127003"], + "gql_mutation_create_comment_perms": ["127005"], "tickets_attachments_root_path": None, "grievance_types": [DEFAULT_STRING, 'Category A', 'Category B'], @@ -40,13 +38,11 @@ class TicketConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = MODULE_NAME gql_query_tickets_perms = [] + gql_query_comments_perms = [] gql_mutation_create_tickets_perms = [] gql_mutation_update_tickets_perms = [] gql_mutation_delete_tickets_perms = [] - gql_query_categorys_perms = [] - gql_mutation_create_categorys_perms = [] - gql_mutation_update_categorys_perms = [] - gql_mutation_delete_categorys_perms = [] + gql_mutation_create_comment_perms = [] tickets_attachments_root_path = None grievance_types = [] diff --git a/grievance_social_protection/gql_mutations.py b/grievance_social_protection/gql_mutations.py index 8c670f4..14c9b35 100644 --- a/grievance_social_protection/gql_mutations.py +++ b/grievance_social_protection/gql_mutations.py @@ -3,14 +3,14 @@ from core.gql.gql_mutations.base_mutation import BaseHistoryModelCreateMutationMixin, BaseMutation, \ BaseHistoryModelUpdateMutationMixin, BaseHistoryModelDeleteMutationMixin from core.schema import OpenIMISMutation -from .models import Ticket, TicketMutation +from .models import Ticket, TicketMutation, Comment -from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ValidationError, PermissionDenied from .apps import TicketConfig from django.utils.translation import gettext_lazy as _ -from .services import TicketService +from .services import TicketService, CommentService +from .validations import user_associated_with_ticket class CreateTicketInputType(OpenIMISMutation.Input): @@ -25,7 +25,7 @@ class TicketStatusEnum(graphene.Enum): title = graphene.String(required=False) description = graphene.String(required=False) reporter_type = graphene.String(required=True, max_lenght=255) - reporter_id = graphene.String(required=True) + reporter_id = graphene.String(required=True, max_lenght=255) attending_staff_id = graphene.UUID(required=False) date_of_incident = graphene.Date(required=False) status = graphene.Field(TicketStatusEnum, required=False) @@ -41,6 +41,13 @@ class UpdateTicketInputType(CreateTicketInputType): id = graphene.UUID(required=True) +class CreateCommentInputType(OpenIMISMutation.Input): + ticket_id = graphene.UUID(required=True) + commenter_type = graphene.String(required=True, max_lenght=255) + commenter_id = graphene.String(required=True, max_lenght=255) + comment = graphene.String(required=True) + + class CreateTicketMutation(BaseHistoryModelCreateMutationMixin, BaseMutation): _mutation_class = "CreateTicketMutation" _mutation_module = "grievance_social_protection" @@ -113,13 +120,46 @@ class DeleteTicketMutation(BaseHistoryModelDeleteMutationMixin, BaseMutation): @classmethod def _validate_mutation(cls, user, **data): super()._validate_mutation(user, **data) - if type(user) is AnonymousUser or not user.has_perms( + if not user.has_perms( TicketConfig.gql_mutation_delete_tickets_perms): raise ValidationError("mutation.authentication_required") class Input(OpenIMISMutation.Input): ids = graphene.List(graphene.UUID) + +class CreateCommentMutation(BaseHistoryModelCreateMutationMixin, BaseMutation): + _mutation_class = "CreateCommentMutation" + _mutation_module = "grievance_social_protection" + _model = Comment + + @classmethod + def _validate_mutation(cls, user, **data): + super()._validate_mutation(user, **data) + if user.has_perms(TicketConfig.gql_mutation_delete_tickets_perms): + return + if user_associated_with_ticket(user): + return + raise ValidationError("mutation.authentication_required") + + @classmethod + def _mutate(cls, user, **data): + if "client_mutation_id" in data: + data.pop('client_mutation_id') + if "client_mutation_label" in data: + data.pop('client_mutation_label') + + data['commenter_type'] = data.get('commenter_type', '').lower() + service = CommentService(user) + response = service.create(data) + + if not response['success']: + return response + return None + + class Input(CreateCommentInputType): + pass + # class CreateTicketAttachmentMutation(OpenIMISMutation): # _mutation_module = "grievance_social_protection" # _mutation_class = "CreateTicketAttachmentMutation" diff --git a/grievance_social_protection/gql_queries.py b/grievance_social_protection/gql_queries.py index e1e82fb..03f2dab 100644 --- a/grievance_social_protection/gql_queries.py +++ b/grievance_social_protection/gql_queries.py @@ -6,17 +6,24 @@ from core.gql_queries import UserGQLType from .apps import TicketConfig -from .models import Ticket +from .models import Ticket, Comment from core import prefix_filterset, ExtendedConnection from .util import model_obj_to_json +from .validations import user_associated_with_ticket -def check_perms(info): +def check_ticket_perms(info): if not info.context.user.has_perms(TicketConfig.gql_query_tickets_perms): raise PermissionDenied(_("unauthorized")) +def check_comment_perms(info): + user = info.context.user + if not (user_associated_with_ticket(user) or user.has_perms(TicketConfig.gql_query_comments_perms)): + raise PermissionDenied(_("Unauthorized")) + + class TicketGQLType(DjangoObjectType): # TODO on resolve check filters and remove anonymized so user can't fetch ticket using last_name if not visible client_mutation_id = graphene.String() @@ -26,17 +33,17 @@ class TicketGQLType(DjangoObjectType): @staticmethod def resolve_reporter_type(root, info): - check_perms(info) + check_ticket_perms(info) return root.reporter_type.id @staticmethod def resolve_reporter_type_name(root, info): - check_perms(info) + check_ticket_perms(info) return root.reporter_type.name @staticmethod def resolve_reporter(root, info): - check_perms(info) + check_ticket_perms(info) return model_obj_to_json(root.reporter) class Meta: @@ -57,7 +64,6 @@ class Meta: "due_date": ["exact", "istartswith", "icontains", "iexact"], "date_of_incident": ["exact", "istartswith", "icontains", "iexact"], "date_created": ["exact", "istartswith", "icontains", "iexact"], - # TODO reporter generic key **prefix_filterset("attending_staff__", UserGQLType._meta.filter_fields), } @@ -69,6 +75,39 @@ def resolve_client_mutation_id(self, info): return ticket_mutation.mutation.client_mutation_id if ticket_mutation else None +class CommentGQLType(DjangoObjectType): + commenter = graphene.JSONString() + commenter_type = graphene.Int() + commenter_type_name = graphene.String() + + @staticmethod + def resolve_commenter_type(root, info): + check_comment_perms(info) + return root.commenter_type.id + + @staticmethod + def resolve_commenter_type_name(root, info): + check_comment_perms(info) + return root.commenter_type.name + + @staticmethod + def resolve_commenter(root, info): + check_comment_perms(info) + return model_obj_to_json(root.commenter) + + class Meta: + model = Comment + interfaces = (graphene.relay.Node,) + filter_fields = { + "id": ["exact", "isnull"], + "comment": ["exact", "istartswith", "icontains", "iexact"], + "date_created": ["exact", "istartswith", "icontains", "iexact"], + **prefix_filterset("ticket__", TicketGQLType._meta.filter_fields), + } + + connection_class = ExtendedConnection + + # class TicketAttachmentGQLType(DjangoObjectType): # class Meta: # model = TicketAttachment diff --git a/grievance_social_protection/migrations/0013_auto_20240422_2108.py b/grievance_social_protection/migrations/0013_auto_20240422_2108.py new file mode 100644 index 0000000..4bab98a --- /dev/null +++ b/grievance_social_protection/migrations/0013_auto_20240422_2108.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.24 on 2024-04-22 21:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('grievance_social_protection', '0012_auto_20240418_1137'), + ] + + operations = [ + migrations.AlterField( + model_name='comment', + name='commenter_id', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='comment', + name='commenter_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='comment', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='grievance_social_protection.ticket'), + ), + migrations.AlterField( + model_name='historicalcomment', + name='commenter_id', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='ticket', + name='reporter_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='contenttypes.contenttype'), + ), + ] diff --git a/grievance_social_protection/migrations/0014_add_grievance_social_protection_rights_to_admin.py b/grievance_social_protection/migrations/0014_add_grievance_social_protection_rights_to_admin.py new file mode 100644 index 0000000..871039f --- /dev/null +++ b/grievance_social_protection/migrations/0014_add_grievance_social_protection_rights_to_admin.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.24 on 2024-04-22 21:18 + +from django.db import migrations + +from core.utils import insert_role_right_for_system, remove_role_right_for_system + +IMIS_ADMIN_ROLE_IS_SYSTEM = 64 + +gql_query_tickets_perms = 127000 +gql_query_comments_perms = 127004 +gql_mutation_create_tickets_perms = 127001 +gql_mutation_update_tickets_perms = 127002 +gql_mutation_delete_tickets_perms = 127003 +gql_mutation_create_comment_perms = 127005 + + + +def add_rights(apps, schema_editor): + """ + Add subscription CRUD permission to the IMIS Administrator. + """ + insert_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_query_tickets_perms) + insert_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_query_comments_perms) + insert_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_mutation_create_tickets_perms) + insert_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_mutation_update_tickets_perms) + insert_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_mutation_delete_tickets_perms) + insert_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_mutation_create_comment_perms) + + +def remove_rights(apps, schema_editor): + """ + Remove subscription CRUD permissions to the IMIS Administrator. + """ + remove_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_query_tickets_perms) + remove_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_query_comments_perms) + remove_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_mutation_create_tickets_perms) + remove_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_mutation_update_tickets_perms) + remove_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_mutation_delete_tickets_perms) + remove_role_right_for_system(IMIS_ADMIN_ROLE_IS_SYSTEM, gql_mutation_create_comment_perms) + + +class Migration(migrations.Migration): + + + dependencies = [ + ('grievance_social_protection', '0013_auto_20240422_2108'), + ] + + operations = [ + migrations.RunPython(add_rights, remove_rights), + ] diff --git a/grievance_social_protection/models.py b/grievance_social_protection/models.py index a555f56..2e2d836 100644 --- a/grievance_social_protection/models.py +++ b/grievance_social_protection/models.py @@ -29,7 +29,7 @@ class TicketStatus(models.TextChoices): description = models.TextField(max_length=255, blank=True, null=True) code = models.CharField(max_length=16, unique=True, blank=True, null=True) - reporter_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=False, blank=False) + reporter_type = models.ForeignKey(ContentType, on_delete=models.DO_NOTHING, null=False, blank=False) reporter_id = models.CharField(max_length=255, null=False, blank=False) reporter = GenericForeignKey('reporter_type', 'reporter_id') @@ -86,9 +86,9 @@ class Meta: class Comment(HistoryModel): - ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, null=False, blank=False) - commenter_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=False, blank=False) - commenter_id = models.PositiveIntegerField(null=False, blank=False) + ticket = models.ForeignKey(Ticket, on_delete=models.DO_NOTHING, null=False, blank=False) + commenter_type = models.ForeignKey(ContentType, on_delete=models.DO_NOTHING, null=False, blank=False) + commenter_id = models.CharField(max_length=255, null=False, blank=False) commenter = GenericForeignKey('commenter_type', 'commenter_id') comment = models.TextField(blank=False, null=False) diff --git a/grievance_social_protection/schema.py b/grievance_social_protection/schema.py index 0e384ca..090d566 100644 --- a/grievance_social_protection/schema.py +++ b/grievance_social_protection/schema.py @@ -36,6 +36,19 @@ class Query(graphene.ObjectType): grievance_config = graphene.Field(GrievanceTypeConfigurationGQLType) + comments = OrderedDjangoFilterConnectionField( + CommentGQLType, + orderBy=graphene.List(of_type=graphene.String), + ) + + def resolve_comments(self, info, **kwargs): + user = info.context.user + + if not (user_associated_with_ticket(user) or user.has_perms(TicketConfig.gql_query_comments_perms)): + raise PermissionDenied(_("Unauthorized")) + + return gql_optimizer.query(Comment.objects.all(), info) + def resolve_ticket_details(self, info, **kwargs): if not info.context.user.has_perms(TicketConfig.gql_query_tickets_perms): raise PermissionDenied(_("unauthorized")) @@ -104,6 +117,8 @@ class Mutation(graphene.ObjectType): update_Ticket = UpdateTicketMutation.Field() delete_Ticket = DeleteTicketMutation.Field() + create_comment = CreateCommentMutation.Field() + # create_ticket_attachment = CreateTicketAttachmentMutation.Field() # update_ticket_attachment = UpdateTicketAttachmentMutation.Field() diff --git a/grievance_social_protection/services.py b/grievance_social_protection/services.py index 31600ed..6be772b 100644 --- a/grievance_social_protection/services.py +++ b/grievance_social_protection/services.py @@ -1,10 +1,13 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Max +from django.db import transaction from core.services import BaseService from core.signals import register_service_signal -from grievance_social_protection.models import Ticket -from grievance_social_protection.validations import TicketValidation +from core.services.utils import check_authentication as check_authentication, output_exception, \ + model_representation, output_result_success +from grievance_social_protection.models import Ticket, Comment +from grievance_social_protection.validations import TicketValidation, CommentValidation class TicketService(BaseService): @@ -44,3 +47,33 @@ def _generate_code(self, obj_data): new_ticket_code = f'GRS{last_ticket_code_numeric + 1:08}' obj_data['code'] = new_ticket_code + + +class CommentService: + OBJECT_TYPE = Comment + + def __init__(self, user, validation_class=CommentValidation): + self.user = user + self.validation_class = validation_class + + @register_service_signal('comment_service.create') + @check_authentication + def create(self, obj_data): + try: + with transaction.atomic(): + self._get_content_type(obj_data) + self.validation_class.validate_create(self.user, **obj_data) + obj_ = self.OBJECT_TYPE(**obj_data) + return self.save_instance(obj_) + except Exception as exc: + return output_exception(model_name=self.OBJECT_TYPE.__name__, method="create", exception=exc) + + def save_instance(self, obj_): + obj_.save(username=self.user.username) + dict_repr = model_representation(obj_) + return output_result_success(dict_representation=dict_repr) + + def _get_content_type(self, obj_data): + content_type = ContentType.objects.get(model=obj_data['commenter_type'].lower()) + obj_data['commenter_type'] = content_type + diff --git a/grievance_social_protection/validations.py b/grievance_social_protection/validations.py index 84ba6ec..b305fcd 100644 --- a/grievance_social_protection/validations.py +++ b/grievance_social_protection/validations.py @@ -2,8 +2,10 @@ from django.utils.translation import gettext as _ from django.contrib.contenttypes.models import ContentType +from core.models import User from core.validation import BaseModelValidation -from grievance_social_protection.models import Ticket +from grievance_social_protection.apps import TicketConfig +from grievance_social_protection.models import Ticket, Comment class TicketValidation(BaseModelValidation): @@ -26,6 +28,64 @@ def validate_update(cls, user, **data): raise ValidationError(errors) +class CommentValidation: + OBJECT_TYPE = Comment + + @classmethod + def validate_create(cls, user, **data): + errors = [ + *validate_ticket_exists(data), + *validate_commenter_exists(data), + *validate_commenter_associated_with_ticket(data) + ] + if errors: + raise ValidationError(errors) + + +def validate_ticket_exists(data): + ticket_id = data.get('ticket_id') + if not Ticket.objects.filter(id=ticket_id).exists(): + return [{"message": _("validations.CommentValidation.validate_ticket_exists") % {"ticket_id": ticket_id}}] + return [] + + +def validate_commenter_exists(data): + commenter_type = data.get('commenter_type') + commenter_id = data.get('commenter_id') + model_class = commenter_type.model_class() + + if not model_class.objects.filter(id=commenter_id).exists(): + return [{"message": _("validations.CommentValidation.validate_commenter_exists")}] + + return [] + + +def validate_commenter_associated_with_ticket(data): + commenter_type = data.get('commenter_type') + commenter_id = data.get('commenter_id') + + model_class = commenter_type.model_class() + commenter = model_class.objects.get(id=commenter_id) + + if isinstance(commenter, User): + attending_staff_tickets = Ticket.objects.filter(attending_staff=commenter) + if attending_staff_tickets.exists(): + return [] + + reporter_tickets = Ticket.objects.filter(reporter_type=commenter_type, reporter_id=commenter_id) + if reporter_tickets.exists(): + return [] + + return [{"message": _("validations.CommentValidation.commenter_not_associated_with_ticket")}] + + +def user_associated_with_ticket(user): + if isinstance(user, User): + if Ticket.objects.filter(attending_staff=user).exists(): + return True + return False + + def validate_ticket_unique_code(data): code = data.get('code') ticket_id = data.get('id') diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 8866d57..d6c9741 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -34,3 +34,12 @@ msgstr "Ticket %(code) already exists." #: grievance_social_protection/validations.py:48 msgid "validations.TicketValidation.validate_if_reporter_exists" msgstr "Reporter does not exist." + +msgid "validations.CommentValidation.validate_ticket_exists" +msgstr "Ticket %(ticket_id) does not exist." + +msgid "validations.CommentValidation.validate_commenter_exists" +msgstr "Commenter does not exist." + +msgid "validations.CommentValidation.commenter_not_associated_with_ticket" +msgstr "Commenter not associated with ticket." \ No newline at end of file