From b27d2ddccb0b3f7c12d7260f7a086b45a5f85ac1 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 15 Mar 2024 09:37:47 +0100 Subject: [PATCH 1/4] CM-742: create payment cycle (#16) * CM-742: create payment cycle * CM-742: update tests --------- Co-authored-by: Jan --- payment_cycle/apps.py | 4 +- payment_cycle/gql_mutations.py | 69 ++++++-- payment_cycle/gql_queries.py | 6 +- .../migrations/0004_add_start_and_end_date.py | 118 ++++++++++++++ .../migrations/0005_remove_run_month.py | 29 ++++ payment_cycle/models.py | 14 +- payment_cycle/schema.py | 26 ++- payment_cycle/services.py | 152 ++---------------- payment_cycle/signals/__init__.py | 17 ++ payment_cycle/tests/__init__.py | 2 +- .../benefitPlanPaymentCycleServiceTests.py | 69 -------- payment_cycle/tests/data.py | 56 ++----- .../tests/paymentCycleServiceTests.py | 47 ++++++ payment_cycle/validations.py | 48 ++++++ 14 files changed, 390 insertions(+), 267 deletions(-) create mode 100644 payment_cycle/migrations/0004_add_start_and_end_date.py create mode 100644 payment_cycle/migrations/0005_remove_run_month.py create mode 100644 payment_cycle/signals/__init__.py delete mode 100644 payment_cycle/tests/benefitPlanPaymentCycleServiceTests.py create mode 100644 payment_cycle/tests/paymentCycleServiceTests.py create mode 100644 payment_cycle/validations.py diff --git a/payment_cycle/apps.py b/payment_cycle/apps.py index 15d38fc..86319cb 100644 --- a/payment_cycle/apps.py +++ b/payment_cycle/apps.py @@ -7,7 +7,7 @@ 'gql_create_payment_cycle_perms': ['200002'], 'gql_update_payment_cycle_perms': ['200003'], 'gql_delete_payment_cycle_perms': ['200004'], - 'gql_mutation_process_payment_cycle_perms': ['200005'] + 'gql_check_payment_cycle_update': True, } @@ -19,7 +19,7 @@ class PaymentCycleConfig(AppConfig): gql_create_payment_cycle_perms = None gql_update_payment_cycle_perms = None gql_delete_payment_cycle_perms = None - gql_mutation_process_payment_cycle_perms = None + gql_check_payment_cycle_update = None def ready(self): from core.models import ModuleConfiguration diff --git a/payment_cycle/gql_mutations.py b/payment_cycle/gql_mutations.py index 622daad..31df6f0 100644 --- a/payment_cycle/gql_mutations.py +++ b/payment_cycle/gql_mutations.py @@ -2,22 +2,73 @@ from django.core.exceptions import PermissionDenied from django.utils.translation import gettext as _ +from core.gql.gql_mutations.base_mutation import BaseHistoryModelCreateMutationMixin, BaseMutation, \ + BaseHistoryModelUpdateMutationMixin from core.schema import OpenIMISMutation from payment_cycle.apps import PaymentCycleConfig -from payment_cycle.services import BenefitPlanPaymentCycleService +from payment_cycle.models import PaymentCycle +from payment_cycle.services import PaymentCycleService -class ProcessBenefitPlanPaymentCycleMutation(OpenIMISMutation): +class CreatePaymentCycleInput(OpenIMISMutation.Input): + class PaymentCycleEnum(graphene.Enum): + PENDING = PaymentCycle.PaymentCycleStatus.PENDING + ACTIVE = PaymentCycle.PaymentCycleStatus.ACTIVE + SUSPENDED = PaymentCycle.PaymentCycleStatus.SUSPENDED + code = graphene.String(required=True) + start_date = graphene.Date(required=True) + end_date = graphene.Date(required=True) + status = graphene.Field(PaymentCycleEnum, required=True) + + +class UpdatePaymentCycleInput(CreatePaymentCycleInput): + id = graphene.UUID(required=True) + + +class CreatePaymentCycleMutation(BaseHistoryModelCreateMutationMixin, BaseMutation): _mutation_module = "payment_cycle" - _mutation_class = "ProcessBenefitPlanPaymentCycleMutation" + _mutation_class = "CreatePaymentCycleMutation" + + @classmethod + def _validate_mutation(cls, user, **data): + super()._validate_mutation(user, **data) + if not user.has_perms(PaymentCycleConfig.gql_create_payment_cycle_perms): + raise PermissionDenied(_("unauthorized")) + + @classmethod + def _mutate(cls, user, **data): + data.pop('client_mutation_id', None) + data.pop('client_mutation_label', None) - class Input(OpenIMISMutation.Input): - year = graphene.Int() - month = graphene.Int() + res = PaymentCycleService(user).create(data) + return res if not res['success'] else None + + class Input(CreatePaymentCycleInput): + pass + + +class UpdatePaymentCycleMutation(BaseHistoryModelUpdateMutationMixin, BaseMutation): + _mutation_module = "payment_cycle" + _mutation_class = "PaymentCycleMutation" + _model = PaymentCycle @classmethod - def async_mutate(cls, user, **data): - if not user.has_perms(PaymentCycleConfig.gql_mutation_process_payment_cycle_perms): + def _validate_mutation(cls, user, **data): + super()._validate_mutation(user, **data) + if not user.has_perms(PaymentCycleConfig.gql_update_payment_cycle_perms): raise PermissionDenied(_("unauthorized")) - res = BenefitPlanPaymentCycleService(user).process(**data) + + @classmethod + def _mutate(cls, user, **data): + data.pop('client_mutation_id', None) + data.pop('client_mutation_label', None) + + service = PaymentCycleService(user) + if PaymentCycleConfig.gql_check_payment_cycle_update: + res = service.create_update_task(data) + else: + res = service.update(data) return res if not res['success'] else None + + class Input(UpdatePaymentCycleInput): + pass diff --git a/payment_cycle/gql_queries.py b/payment_cycle/gql_queries.py index 1700d3a..3b3c8bc 100644 --- a/payment_cycle/gql_queries.py +++ b/payment_cycle/gql_queries.py @@ -13,8 +13,10 @@ class Meta: interfaces = (graphene.relay.Node,) filter_fields = { "id": ["exact"], - "run_year": ["exact"], - "run_month": ["exact"], + "code": ["exact", "istartswith", "icontains", "iexact"], + "start_date": ["exact", "lt", "lte", "gt", "gte"], + "end_date": ["exact", "lt", "lte", "gt", "gte"], + "status": ["exact", "istartswith", "icontains", "iexact"], "type_id": ["exact"], "date_created": ["exact", "lt", "lte", "gt", "gte"], diff --git a/payment_cycle/migrations/0004_add_start_and_end_date.py b/payment_cycle/migrations/0004_add_start_and_end_date.py new file mode 100644 index 0000000..7707138 --- /dev/null +++ b/payment_cycle/migrations/0004_add_start_and_end_date.py @@ -0,0 +1,118 @@ +from django.db import migrations, models +import string +import random +import core.fields +from django.utils.translation import gettext_lazy as _ +from datetime import date +from calendar import monthrange, month_name + + +def generate_random_code(): + characters = string.ascii_letters + string.digits + return ''.join(random.choice(characters) for _ in range(8)) + + +def get_date(year, month): + first_day, last_day = monthrange(year, month) + return date(year, month, first_day), date(year, month, last_day) + + +def create_code_from_date(apps, schema_editor): + PaymentCycle = apps.get_model('payment_cycle', 'PaymentCycle') + for payment_cycle in PaymentCycle.objects.all(): + month_name_string = month_name[payment_cycle.run_month] + year_month_code = f"{payment_cycle.run_year}-{month_name_string}" + + new_code = year_month_code + while PaymentCycle.objects.filter(code=new_code).exists(): + new_code = f"{year_month_code}-{generate_random_code()}" + + payment_cycle.code = new_code + payment_cycle.save() + + +def set_default_start_end_date_and_status(apps, schema_editor): + PaymentCycle = apps.get_model('payment_cycle', 'PaymentCycle') + + for payment_cycle in PaymentCycle.objects.all(): + start_date, end_date = get_date(payment_cycle.run_year, payment_cycle.run_month) + payment_cycle.start_date = start_date + payment_cycle.end_date = end_date + payment_cycle.save() + + +def create_unique_code_historical(apps, schema_editor): + PaymentCycle = apps.get_model('payment_cycle', 'PaymentCycle') + HistoricalPaymentCycle = apps.get_model('payment_cycle', 'HistoricalPaymentCycle') + + for payment_cycle in PaymentCycle.objects.all(): + historical_payment_cycles = HistoricalPaymentCycle.objects.filter(id=payment_cycle.id) + for historical in historical_payment_cycles: + historical.code = payment_cycle.code + historical.save() + + +def set_default_start_end_date_and_status_historical(apps, schema_editor): + HistoricalPaymentCycle = apps.get_model('payment_cycle', 'HistoricalPaymentCycle') + + for payment_cycle in HistoricalPaymentCycle.objects.all(): + start_date, end_date = get_date(payment_cycle.run_year, payment_cycle.run_month) + payment_cycle.start_date = start_date + payment_cycle.end_date = end_date + payment_cycle.save() + + +class Migration(migrations.Migration): + dependencies = [ + ('payment_cycle', '0003_alter_historicalpaymentcycle_date_created_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='paymentcycle', + name='code', + field=models.CharField(max_length=255), + ), + migrations.AddField( + model_name='paymentcycle', + name='start_date', + field=core.fields.DateField(), + ), + migrations.AddField( + model_name='paymentcycle', + name='end_date', + field=core.fields.DateField(), + ), + migrations.AddField( + model_name='paymentcycle', + name='status', + field=models.CharField(max_length=255, choices=[('PENDING', _('PENDING')), ('ACTIVE', _('ACTIVE')), + ('SUSPENDED', _('SUSPENDED'))], default='PENDING'), + ), + migrations.RunPython(set_default_start_end_date_and_status), + migrations.RunPython(create_code_from_date), + # historical + migrations.AddField( + model_name='historicalpaymentcycle', + name='code', + field=models.CharField(max_length=255), + ), + migrations.AddField( + model_name='historicalpaymentcycle', + name='start_date', + field=core.fields.DateField(), + ), + migrations.AddField( + model_name='historicalpaymentcycle', + name='end_date', + field=core.fields.DateField(), + ), + migrations.AddField( + model_name='historicalpaymentcycle', + name='status', + field=models.CharField(max_length=255, choices=[('PENDING', _('PENDING')), ('ACTIVE', _('ACTIVE')), + ('SUSPENDED', _('SUSPENDED'))], default='PENDING'), + ), + migrations.RunPython(set_default_start_end_date_and_status_historical), + migrations.RunPython(create_unique_code_historical), + ] diff --git a/payment_cycle/migrations/0005_remove_run_month.py b/payment_cycle/migrations/0005_remove_run_month.py new file mode 100644 index 0000000..87cd43c --- /dev/null +++ b/payment_cycle/migrations/0005_remove_run_month.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.24 on 2024-03-11 13:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment_cycle', '0004_add_start_and_end_date'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicalpaymentcycle', + name='run_year', + ), + migrations.RemoveField( + model_name='historicalpaymentcycle', + name='run_month', + ), + migrations.RemoveField( + model_name='paymentcycle', + name='run_year', + ), + migrations.RemoveField( + model_name='paymentcycle', + name='run_month', + ), + ] diff --git a/payment_cycle/models.py b/payment_cycle/models.py index d7d83cb..228aac7 100644 --- a/payment_cycle/models.py +++ b/payment_cycle/models.py @@ -1,10 +1,20 @@ from django.contrib.contenttypes.models import ContentType from django.db import models +from gettext import gettext as _ + from core.models import HistoryModel +from core.fields import DateField class PaymentCycle(HistoryModel): - run_year = models.IntegerField() - run_month = models.SmallIntegerField() + class PaymentCycleStatus(models.TextChoices): + PENDING = 'PENDING', _('PENDING') + ACTIVE = 'ACTIVE', _('ACTIVE') + SUSPENDED = 'SUSPENDED', _('SUSPENDED') + + code = models.CharField(max_length=255, blank=False, null=False) + start_date = DateField(blank=False, null=False) + end_date = DateField(blank=False, null=False) + status = models.CharField(max_length=255, choices=PaymentCycleStatus.choices, default=PaymentCycleStatus.PENDING) type = models.ForeignKey(ContentType, on_delete=models.DO_NOTHING, blank=True, null=True, unique=False) diff --git a/payment_cycle/schema.py b/payment_cycle/schema.py index ccdae4b..f80f589 100644 --- a/payment_cycle/schema.py +++ b/payment_cycle/schema.py @@ -1,14 +1,18 @@ import graphene import graphene_django_optimizer as gql_optimizer from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import PermissionDenied from django.db.models import Q +from django.utils.translation import gettext as _ +from core.gql_queries import ValidationMessageGQLType from core.schema import OrderedDjangoFilterConnectionField from core.utils import append_validity_filter from payment_cycle.apps import PaymentCycleConfig -from payment_cycle.gql_mutations import ProcessBenefitPlanPaymentCycleMutation +from payment_cycle.gql_mutations import CreatePaymentCycleMutation, UpdatePaymentCycleMutation from payment_cycle.gql_queries import PaymentCycleGQLType from payment_cycle.models import PaymentCycle +from payment_cycle.validations import validate_payment_cycle_unique_code, validate_payment_cycle_whitespace_code class Query(graphene.ObjectType): @@ -20,6 +24,23 @@ class Query(graphene.ObjectType): applyDefaultValidityFilter=graphene.Boolean(), search=graphene.String(), ) + payment_cycle_code_validity = graphene.Field( + ValidationMessageGQLType, + code=graphene.String(required=True), + description="Checks that the specified Benefit Plan code is valid" + ) + + def resolve_payment_cycle_code_validity(self, info, **kwargs): + if not info.context.user.has_perms(PaymentCycleConfig.gql_query_payment_cycle_perms): + raise PermissionDenied(_("unauthorized")) + code = kwargs['code'] + errors = [*validate_payment_cycle_unique_code(code), *validate_payment_cycle_whitespace_code(code)] + + if errors: + return ValidationMessageGQLType(False, error_message=errors[0]['message']) + else: + return ValidationMessageGQLType(True) + def resolve_payment_cycle(self, info, **kwargs): filters = append_validity_filter(**kwargs) @@ -39,4 +60,5 @@ def _check_permissions(user, perms): class Mutation(graphene.ObjectType): - process_benefit_plan_payment_cycle = ProcessBenefitPlanPaymentCycleMutation.Field() + create_payment_cycle = CreatePaymentCycleMutation.Field() + update_payment_cycle = UpdatePaymentCycleMutation.Field() diff --git a/payment_cycle/services.py b/payment_cycle/services.py index cb9e15e..28db3c9 100644 --- a/payment_cycle/services.py +++ b/payment_cycle/services.py @@ -1,142 +1,24 @@ -import calendar -import datetime -import logging -from abc import ABC, abstractmethod -from typing import Dict, Union, Any - -from django.contrib.contenttypes.models import ContentType -from django.db.models import Q, QuerySet -from django.forms import model_to_dict - -from calculation.services import get_calculation_object -from contribution_plan.models import PaymentPlan -from core.models import InteractiveUser, VersionedModel, HistoryModel, User -from core.services.utils import output_exception, output_result_success +from core.services import BaseService +from core.signals import register_service_signal from payment_cycle.models import PaymentCycle -from social_protection.models import BenefitPlan - -logger = logging.getLogger(__name__) - - -class PaymentCycleService(ABC): - """ - Generic payment cycle service specifying workflow for generating payment cycle information for a specific business - models. Based on Batch Run. - """ - - def __init__(self, user: Union[InteractiveUser, User]): - self._user = user - self._audit_user_id = user.id if isinstance(self._user, InteractiveUser) else user.i_user_id - self._username = user.login_name if isinstance(self._user, InteractiveUser) else user.username - - def process(self, **kwargs) -> Dict: - """ - Perform a processing run of payment cycle for a specific year and month. The run will only be performed if - the run for that month does not exist yet. - :param kwargs: Any arguments required to process the payment cycle. year and month are required - :return: Dict containing status of a current payment cycle process run - """ - payment_cycle_entry = self._create_payment_cycle_entry(**kwargs) - payment_cycle_dict = model_to_dict(payment_cycle_entry) - payment_cycle_dict = {**payment_cycle_dict, 'id': payment_cycle_entry.id} - return output_result_success(payment_cycle_dict) - - @abstractmethod - def _process_main_queryset_entry(self, entry: Union[HistoryModel, VersionedModel], - payment_cycle_entry: PaymentCycle, end_date: datetime, **kwargs) -> Any: - """ - Process a single entry returned from _get_main_queryset(). - :param payment_cycle_entry: Model of a current payment cycle process entry - :param entry: Model of a current benefit plan to process - :param end_date: Date indicating last day of the current month - :return: Status of processing a single entry from main queryset. Will be collected and returned from process() - """ - raise NotImplementedError('`process_main_queryset_entry` must be implemented.') - - @abstractmethod - def _get_main_queryset(self, end_date: datetime, **kwargs) -> QuerySet: - """ - Return a queryset of all active entries to process payment cycles for. - :param end_date: Date indicating last day of the current month - :return: A queryset of all active objects to process payment cycles for - """ - raise NotImplementedError('`get_main_queryset` must be implemented.') - - @abstractmethod - def _create_payment_cycle_entry(self, **kwargs) -> PaymentCycle: - """ - Create PaymentCycle object logging a payment cycle process for a specific year and month. Can be customized for - current object type. - :return: PaymentCycle object for a current process - """ - raise NotImplementedError('`create_payment_cycle_entry` must be implemented.') - - @abstractmethod - def _payment_cycle_entry_exists(self, **kwargs) -> bool: - """ - Check if an entry of PaymentCycle object logging a payment cycle process for a specific year and month already - exists. The run will be skipped if it does. - :return: - """ - raise NotImplementedError('`payment_cycle_entry_exists` must be implemented.') - - @staticmethod - def _output_exception(error, method='process', model='PaymentCycle'): - output_exception(error, method, model) - - @staticmethod - def _get_start_date(end_date, periodicity): - year = end_date.year - month = end_date.month - if periodicity not in [1, 2, 3, 4, 6, 12]: - return None - return datetime.date(year, month - periodicity + 1, 1) if month % periodicity == 0 else None - - -class BenefitPlanPaymentCycleService(PaymentCycleService): +from payment_cycle.validations import PaymentCycleValidation +from tasks_management.services import UpdateCheckerLogicServiceMixin - def __init__(self, user): - super().__init__(user) - self._bf_content_type = ContentType.objects.get_for_model(BenefitPlan) - def _process_main_queryset_entry(self, entry: Union[HistoryModel, VersionedModel], - payment_cycle_entry: PaymentCycle, end_date: datetime, **kwargs) -> Any: - payment_plans = self._get_payment_plan_queryset(entry, end_date) - for payment_plan in payment_plans: - start_date = self._get_start_date(end_date, payment_plan.periodicity) - if not start_date: - # Not a month of payment - continue - calculation = get_calculation_object(payment_plan.calculation) - if not calculation: - logger.warning("Payment plan with not existent calculation: %s", payment_plan.id) - continue - return calculation.calculate_if_active_for_object(payment_plan, payment_cycle=payment_cycle_entry, - audit_user_id=self._audit_user_id, start_date=start_date, - end_date=end_date) +class PaymentCycleService(BaseService, UpdateCheckerLogicServiceMixin): + OBJECT_TYPE = PaymentCycle - def _payment_cycle_entry_exists(self, **kwargs) -> bool: - return PaymentCycle.objects.filter( - run_year=kwargs['year'], - run_month=kwargs['month'], - type=self._bf_content_type, - is_deleted=False - ).exists() + def __init__(self, user, validation_class=PaymentCycleValidation): + super().__init__(user, validation_class) - def _create_payment_cycle_entry(self, **kwargs) -> PaymentCycle: - pc = PaymentCycle(run_year=kwargs['year'], run_month=kwargs['month'], type=self._bf_content_type) - pc.save(username=self._username) - return pc + @register_service_signal('payment_cycle_service.create') + def create(self, obj_data): + return super().create(obj_data) - def _get_main_queryset(self, end_date: datetime, **kwargs) -> QuerySet: - return BenefitPlan.objects \ - .filter(date_valid_from__lte=end_date, is_deleted=False) \ - .filter(Q(date_valid_to__gte=end_date) | Q(date_valid_to__isnull=True)) + @register_service_signal('payment_cycle_service.update') + def update(self, obj_data): + return super().update(obj_data) - def _get_payment_plan_queryset(self, entry: Union[HistoryModel, VersionedModel], end_date: datetime): - return PaymentPlan.objects \ - .filter(date_valid_to__gte=end_date) \ - .filter(date_valid_from__lte=end_date) \ - .filter(benefit_plan_id=entry.id) \ - .filter(benefit_plan_type=self._bf_content_type) \ - .filter(is_deleted=False) + @register_service_signal('payment_cycle_service.delete') + def delete(self, obj_data): + return super().delete(obj_data) diff --git a/payment_cycle/signals/__init__.py b/payment_cycle/signals/__init__.py new file mode 100644 index 0000000..fdd7cdb --- /dev/null +++ b/payment_cycle/signals/__init__.py @@ -0,0 +1,17 @@ +import logging + +from core.service_signals import ServiceSignalBindType +from core.signals import bind_service_signal +from payment_cycle.services import PaymentCycleService +from tasks_management.services import on_task_complete_service_handler + +logger = logging.getLogger(__name__) + + +def bind_service_signals(): + bind_service_signal( + 'task_service.complete_task', + on_task_complete_service_handler(PaymentCycleService), + bind_type=ServiceSignalBindType.AFTER + ) + diff --git a/payment_cycle/tests/__init__.py b/payment_cycle/tests/__init__.py index 3b8f943..54a76c9 100644 --- a/payment_cycle/tests/__init__.py +++ b/payment_cycle/tests/__init__.py @@ -1 +1 @@ -from payment_cycle.tests.benefitPlanPaymentCycleServiceTests import BenefitPlanPaymentCycleServiceTests +from payment_cycle.tests.paymentCycleServiceTests import PaymentCycleServiceTests diff --git a/payment_cycle/tests/benefitPlanPaymentCycleServiceTests.py b/payment_cycle/tests/benefitPlanPaymentCycleServiceTests.py deleted file mode 100644 index 2eb406c..0000000 --- a/payment_cycle/tests/benefitPlanPaymentCycleServiceTests.py +++ /dev/null @@ -1,69 +0,0 @@ -from django.contrib.contenttypes.models import ContentType -from django.test import TestCase - -from contribution_plan.models import PaymentPlan -from core.datetimes.ad_datetime import datetime -from individual.models import Individual -from invoice.models import Bill -from payment_cycle.models import PaymentCycle -from payment_cycle.tests.data import benefit_plan_payload, individual_payload, beneficiary_payload, payment_plan_payload -from payment_cycle.services import BenefitPlanPaymentCycleService -from payment_cycle.tests.helpers import LogInHelper -from social_protection.models import BenefitPlan, Beneficiary - - -class BenefitPlanPaymentCycleServiceTests(TestCase): - user = None - service = None - - test_benefit_plan = None - test_individual_1 = None - test_individual_2 = None - test_beneficiary_1 = None - test_beneficiary_2 = None - test_payment_plan = None - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.user = LogInHelper().get_or_create_user_api() - cls.service = BenefitPlanPaymentCycleService(cls.user) - - cls.test_benefit_plan = BenefitPlan(**benefit_plan_payload) - cls.test_benefit_plan.save(username=cls.user.username) - - cls.test_individual_1 = Individual(**individual_payload) - cls.test_individual_1.save(username=cls.user.username) - - cls.test_individual_2 = Individual(**{**individual_payload, 'first_name': 'tests2'}) - cls.test_individual_2.save(username=cls.user.username) - - cls.test_beneficiary_1 = Beneficiary(**{ - **beneficiary_payload, - "individual_id": cls.test_individual_1.id, - "benefit_plan_id": cls.test_benefit_plan.id - }) - cls.test_beneficiary_1.save(username=cls.user.username) - - cls.test_beneficiary_2 = Beneficiary(**{ - **beneficiary_payload, - "individual_id": cls.test_individual_2.id, - "benefit_plan_id": cls.test_benefit_plan.id - }) - cls.test_beneficiary_2.save(username=cls.user.username) - - cls.test_payment_plan = PaymentPlan(**{ - **payment_plan_payload, - 'benefit_plan_id': cls.test_benefit_plan.id, - 'benefit_plan_type': ContentType.objects.get_for_model(BenefitPlan) - }) - cls.test_payment_plan.save(username=cls.user.username) - - def test_trigger_payment_cycle(self): - t = datetime.now() - output = self.service.process(year=t.year, month=t.month) - - self.assertTrue(output) - self.assertTrue(output['success']) - self.assertEqual(2, Bill.objects.count()) - self.assertEqual(1, PaymentCycle.objects.count()) diff --git a/payment_cycle/tests/data.py b/payment_cycle/tests/data.py index 2cdea55..d0cfc9a 100644 --- a/payment_cycle/tests/data.py +++ b/payment_cycle/tests/data.py @@ -1,50 +1,16 @@ from core.datetimes.ad_datetime import datetime +from payment_cycle.models import PaymentCycle -benefit_plan_payload = { - "code": "example", - "name": "example_name", - "max_beneficiaries": 0, - "ceiling_per_beneficiary": "0.00", - "beneficiary_data_schema": { - "$schema": "https://json-schema.org/draft/2019-09/schema" - }, - "date_valid_from": "2023-01-01", - "date_valid_to": "2043-12-31", +service_add_payload = { + 'code': 'ELOO', + 'startDate': '2021-05-01', + 'endDate': '2021-06-01', + 'status': PaymentCycle.PaymentCycleStatus.PENDING, } -individual_payload = { - 'first_name': 'TestFN', - 'last_name': 'TestLN', - 'dob': datetime.now(), - 'json_ext': { - 'key': 'value', - 'key2': 'value2' - } -} - -beneficiary_payload = { - "status": "ACTIVE", - "date_valid_from": "2023-01-01", - "date_valid_to": "2043-12-31", -} - -payment_plan_payload = { - 'code': 'test', - 'name': 'TestPlan', - "date_valid_from": "2023-01-01", - "date_valid_to": "2043-12-31", - 'calculation': '32d96b58-898a-460a-b357-5fd4b95cd87c', - 'periodicity': 1, - 'json_ext': { - 'calculation_rule': { - 'fixed_batch': 1500, - 'limit_per_single_transaction': 100000, - }, - 'advanced_criteria': [ - { - 'custom_filter_condition': 'able_bodied__exact__boolean=False', - 'count_to_max': False, 'amount': 2000 - } - ] - } +service_update_payload = { + 'code': 'ELOO', + 'startDate': '2021-05-01', + 'endDate': '2021-06-01', + 'status': PaymentCycle.PaymentCycleStatus.ACTIVE, } diff --git a/payment_cycle/tests/paymentCycleServiceTests.py b/payment_cycle/tests/paymentCycleServiceTests.py new file mode 100644 index 0000000..55637e4 --- /dev/null +++ b/payment_cycle/tests/paymentCycleServiceTests.py @@ -0,0 +1,47 @@ +import copy + +from django.test import TestCase + +from payment_cycle.models import PaymentCycle +from payment_cycle.tests.data import service_add_payload, service_update_payload +from payment_cycle.services import PaymentCycleService +from payment_cycle.tests.helpers import LogInHelper + + +class PaymentCycleServiceTests(TestCase): + user = None + service = None + + test_benefit_plan = None + test_individual_1 = None + test_individual_2 = None + test_beneficiary_1 = None + test_beneficiary_2 = None + test_payment_plan = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = LogInHelper().get_or_create_user_api() + cls.service = PaymentCycleService(cls.user) + cls.query_all = PaymentCycle.objects.filter(is_deleted=False) + + def test_create_payment_cycle(self): + result = self.service.create(service_add_payload) + self.assertTrue(result.get('success', False), result.get('detail', "No details provided")) + uuid = result.get('data', {}).get('uuid', None) + query = self.query_all.filter(uuid=uuid) + self.assertEqual(query.count(), 1) + + + def test_update_payment_cycle(self): + result = self.service.create(service_add_payload) + self.assertTrue(result.get('success', False), result.get('detail', "No details provided")) + uuid = result.get('data', {}).get('uuid') + update_payload = copy.deepcopy(service_update_payload) + update_payload['id'] = uuid + result = self.service.update(update_payload) + self.assertTrue(result.get('success', False), result.get('detail', "No details provided")) + query = self.query_all.filter(uuid=uuid) + self.assertEqual(query.count(), 1) + self.assertEqual(query.first().status, update_payload.get('status')) diff --git a/payment_cycle/validations.py b/payment_cycle/validations.py new file mode 100644 index 0000000..90796f4 --- /dev/null +++ b/payment_cycle/validations.py @@ -0,0 +1,48 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + +from core.validation import ObjectExistsValidationMixin, UniqueCodeValidationMixin, BaseModelValidation +from core.validation.stringFieldValidationMixin import StringFieldValidationMixin +from payment_cycle.models import PaymentCycle + + +class PaymentCycleValidation(BaseModelValidation, + UniqueCodeValidationMixin, + ObjectExistsValidationMixin, + StringFieldValidationMixin): + OBJECT_TYPE = PaymentCycle + + @classmethod + def validate_create(cls, user, **data): + cls.validate_unique_code_name(data.get('code', None)) + + @classmethod + def validate_update(cls, user, **data): + cls.validate_object_exists(data.get('id', None)) + code = data.get('code', None) + id_ = data.get('id', None) + + if code: + cls.validate_unique_code_name(code, id_) + + + +def validate_payment_cycle_unique_code(code, uuid=None): + try: + PaymentCycleValidation().validate_unique_code_name(code, uuid) + return [] + except ValidationError as e: + return [{"message": _("payment_cycle.validation.payment_cycle.code_exists" % { + 'code': code + })}] + + +def validate_payment_cycle_whitespace_code(code, uuid=None): + try: + PaymentCycleValidation().validate_empty_string(code) + PaymentCycleValidation().validate_string_whitespace_end(code) + PaymentCycleValidation().validate_string_whitespace_start(code) + return [] + except ValidationError as e: + return [{"message": _("payment_cycle.validation.payment_cycle.code_whitespace")}] + From 4cb1f9540f60d57ab5c2e6941c67577ba21fabdd Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 15 Mar 2024 11:08:02 +0100 Subject: [PATCH 2/4] CM-742: fix migration (#17) * CM-742: create payment cycle * CM-742: update tests * CM-742: fix migration --------- Co-authored-by: Jan --- .../migrations/0004_add_start_and_end_date.py | 16 ++++--- .../migrations/0006_auto_20240315_0943.py | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 payment_cycle/migrations/0006_auto_20240315_0943.py diff --git a/payment_cycle/migrations/0004_add_start_and_end_date.py b/payment_cycle/migrations/0004_add_start_and_end_date.py index 7707138..5e6bbba 100644 --- a/payment_cycle/migrations/0004_add_start_and_end_date.py +++ b/payment_cycle/migrations/0004_add_start_and_end_date.py @@ -12,6 +12,10 @@ def generate_random_code(): return ''.join(random.choice(characters) for _ in range(8)) +def get_default_date(): + return date(1999, 6, 28) + + def get_date(year, month): first_day, last_day = monthrange(year, month) return date(year, month, first_day), date(year, month, last_day) @@ -71,17 +75,17 @@ class Migration(migrations.Migration): migrations.AddField( model_name='paymentcycle', name='code', - field=models.CharField(max_length=255), + field=models.CharField(max_length=255, default=generate_random_code()), ), migrations.AddField( model_name='paymentcycle', name='start_date', - field=core.fields.DateField(), + field=core.fields.DateField(default=get_default_date()), ), migrations.AddField( model_name='paymentcycle', name='end_date', - field=core.fields.DateField(), + field=core.fields.DateField(default=get_default_date()), ), migrations.AddField( model_name='paymentcycle', @@ -95,17 +99,17 @@ class Migration(migrations.Migration): migrations.AddField( model_name='historicalpaymentcycle', name='code', - field=models.CharField(max_length=255), + field=models.CharField(max_length=255, default=generate_random_code()), ), migrations.AddField( model_name='historicalpaymentcycle', name='start_date', - field=core.fields.DateField(), + field=core.fields.DateField(default=get_default_date()), ), migrations.AddField( model_name='historicalpaymentcycle', name='end_date', - field=core.fields.DateField(), + field=core.fields.DateField(default=get_default_date()), ), migrations.AddField( model_name='historicalpaymentcycle', diff --git a/payment_cycle/migrations/0006_auto_20240315_0943.py b/payment_cycle/migrations/0006_auto_20240315_0943.py new file mode 100644 index 0000000..8a9f6e7 --- /dev/null +++ b/payment_cycle/migrations/0006_auto_20240315_0943.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.24 on 2024-03-15 09:43 + +import core.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment_cycle', '0005_remove_run_month'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalpaymentcycle', + name='code', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='historicalpaymentcycle', + name='end_date', + field=core.fields.DateField(), + ), + migrations.AlterField( + model_name='historicalpaymentcycle', + name='start_date', + field=core.fields.DateField(), + ), + migrations.AlterField( + model_name='paymentcycle', + name='code', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='paymentcycle', + name='end_date', + field=core.fields.DateField(), + ), + migrations.AlterField( + model_name='paymentcycle', + name='start_date', + field=core.fields.DateField(), + ), + ] From 5827080ae35a417611a9786fd00bfaa56ced9c27 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 15 Mar 2024 11:40:56 +0100 Subject: [PATCH 3/4] CM-742: fix migration (#18) * CM-742: create payment cycle * CM-742: update tests * CM-742: fix migration * CM-742: fix migration --------- Co-authored-by: Jan --- payment_cycle/migrations/0004_add_start_and_end_date.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/payment_cycle/migrations/0004_add_start_and_end_date.py b/payment_cycle/migrations/0004_add_start_and_end_date.py index 5e6bbba..54823b4 100644 --- a/payment_cycle/migrations/0004_add_start_and_end_date.py +++ b/payment_cycle/migrations/0004_add_start_and_end_date.py @@ -17,7 +17,8 @@ def get_default_date(): def get_date(year, month): - first_day, last_day = monthrange(year, month) + first_day = 1 + _, last_day = monthrange(year, month) return date(year, month, first_day), date(year, month, last_day) From 844bc34a59eb8ef443f6ec9f2b8a785f6fb235cf Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 20 Mar 2024 14:51:14 +0100 Subject: [PATCH 4/4] CM-742: payment cycle adjustments (#19) Co-authored-by: Jan --- payment_cycle/apps.py | 4 +-- payment_cycle/gql_mutations.py | 28 ++++++++++++++---- .../migrations/0007_paymentcyclemutation.py | 29 +++++++++++++++++++ payment_cycle/models.py | 10 +++++-- payment_cycle/schema.py | 1 + payment_cycle/services.py | 4 +-- 6 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 payment_cycle/migrations/0007_paymentcyclemutation.py diff --git a/payment_cycle/apps.py b/payment_cycle/apps.py index 86319cb..3814ffd 100644 --- a/payment_cycle/apps.py +++ b/payment_cycle/apps.py @@ -7,7 +7,7 @@ 'gql_create_payment_cycle_perms': ['200002'], 'gql_update_payment_cycle_perms': ['200003'], 'gql_delete_payment_cycle_perms': ['200004'], - 'gql_check_payment_cycle_update': True, + 'gql_check_payment_cycle': True, } @@ -19,7 +19,7 @@ class PaymentCycleConfig(AppConfig): gql_create_payment_cycle_perms = None gql_update_payment_cycle_perms = None gql_delete_payment_cycle_perms = None - gql_check_payment_cycle_update = None + gql_check_payment_cycle = None def ready(self): from core.models import ModuleConfiguration diff --git a/payment_cycle/gql_mutations.py b/payment_cycle/gql_mutations.py index 31df6f0..78a378f 100644 --- a/payment_cycle/gql_mutations.py +++ b/payment_cycle/gql_mutations.py @@ -6,7 +6,7 @@ BaseHistoryModelUpdateMutationMixin from core.schema import OpenIMISMutation from payment_cycle.apps import PaymentCycleConfig -from payment_cycle.models import PaymentCycle +from payment_cycle.models import PaymentCycle, PaymentCycleMutation from payment_cycle.services import PaymentCycleService @@ -15,6 +15,7 @@ class PaymentCycleEnum(graphene.Enum): PENDING = PaymentCycle.PaymentCycleStatus.PENDING ACTIVE = PaymentCycle.PaymentCycleStatus.ACTIVE SUSPENDED = PaymentCycle.PaymentCycleStatus.SUSPENDED + code = graphene.String(required=True) start_date = graphene.Date(required=True) end_date = graphene.Date(required=True) @@ -37,10 +38,21 @@ def _validate_mutation(cls, user, **data): @classmethod def _mutate(cls, user, **data): - data.pop('client_mutation_id', None) + client_mutation_id = data.pop('client_mutation_id', None) data.pop('client_mutation_label', None) - res = PaymentCycleService(user).create(data) + service = PaymentCycleService(user) + if (PaymentCycleConfig.gql_check_payment_cycle and + data["status"] == PaymentCycle.PaymentCycleStatus.ACTIVE): + res = service.create_create_task(data) + else: + res = service.create(data) + if client_mutation_id and res['success']: + payment_cycle_id = res['data']['id'] + payment_cycle = PaymentCycle.objects.get(id=payment_cycle_id) + PaymentCycleMutation.object_mutated( + user, client_mutation_id=client_mutation_id, payment_cycle=payment_cycle + ) return res if not res['success'] else None class Input(CreatePaymentCycleInput): @@ -60,14 +72,20 @@ def _validate_mutation(cls, user, **data): @classmethod def _mutate(cls, user, **data): - data.pop('client_mutation_id', None) + client_mutation_id = data.pop('client_mutation_id', None) data.pop('client_mutation_label', None) service = PaymentCycleService(user) - if PaymentCycleConfig.gql_check_payment_cycle_update: + if PaymentCycleConfig.gql_check_payment_cycle: res = service.create_update_task(data) else: res = service.update(data) + if client_mutation_id and res['success']: + payment_cycle_id = data["id"] + payment_cycle = PaymentCycle.objects.get(id=payment_cycle_id) + PaymentCycleMutation.object_mutated( + user, client_mutation_id=client_mutation_id, payment_cycle=payment_cycle + ) return res if not res['success'] else None class Input(UpdatePaymentCycleInput): diff --git a/payment_cycle/migrations/0007_paymentcyclemutation.py b/payment_cycle/migrations/0007_paymentcyclemutation.py new file mode 100644 index 0000000..f717cbe --- /dev/null +++ b/payment_cycle/migrations/0007_paymentcyclemutation.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.24 on 2024-03-20 12:33 + +import core.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0027_alter_interactiveuser_last_login_and_more'), + ('payment_cycle', '0006_auto_20240315_0943'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentCycleMutation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('mutation', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='payment_cycle', to='core.mutationlog')), + ('payment_cycle', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='mutations', to='payment_cycle.paymentcycle')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, core.models.ObjectMutation), + ), + ] diff --git a/payment_cycle/models.py b/payment_cycle/models.py index 228aac7..bb70a1a 100644 --- a/payment_cycle/models.py +++ b/payment_cycle/models.py @@ -3,7 +3,7 @@ from gettext import gettext as _ -from core.models import HistoryModel +from core.models import HistoryModel, ObjectMutation, UUIDModel, MutationLog from core.fields import DateField @@ -17,4 +17,10 @@ class PaymentCycleStatus(models.TextChoices): start_date = DateField(blank=False, null=False) end_date = DateField(blank=False, null=False) status = models.CharField(max_length=255, choices=PaymentCycleStatus.choices, default=PaymentCycleStatus.PENDING) - type = models.ForeignKey(ContentType, on_delete=models.DO_NOTHING, blank=True, null=True, unique=False) + type = models.ForeignKey(ContentType, on_delete=models.DO_NOTHING, blank=True, null=True, unique=False) + + +class PaymentCycleMutation(UUIDModel, ObjectMutation): + payment_cycle = models.ForeignKey(PaymentCycle, models.DO_NOTHING, related_name='mutations') + mutation = models.ForeignKey( + MutationLog, models.DO_NOTHING, related_name='payment_cycle') diff --git a/payment_cycle/schema.py b/payment_cycle/schema.py index f80f589..1940d6d 100644 --- a/payment_cycle/schema.py +++ b/payment_cycle/schema.py @@ -23,6 +23,7 @@ class Query(graphene.ObjectType): dateValidTo__Lte=graphene.DateTime(), applyDefaultValidityFilter=graphene.Boolean(), search=graphene.String(), + client_mutation_id=graphene.String(), ) payment_cycle_code_validity = graphene.Field( ValidationMessageGQLType, diff --git a/payment_cycle/services.py b/payment_cycle/services.py index 28db3c9..350166a 100644 --- a/payment_cycle/services.py +++ b/payment_cycle/services.py @@ -2,10 +2,10 @@ from core.signals import register_service_signal from payment_cycle.models import PaymentCycle from payment_cycle.validations import PaymentCycleValidation -from tasks_management.services import UpdateCheckerLogicServiceMixin +from tasks_management.services import UpdateCheckerLogicServiceMixin, CreateCheckerLogicServiceMixin -class PaymentCycleService(BaseService, UpdateCheckerLogicServiceMixin): +class PaymentCycleService(BaseService, UpdateCheckerLogicServiceMixin, CreateCheckerLogicServiceMixin): OBJECT_TYPE = PaymentCycle def __init__(self, user, validation_class=PaymentCycleValidation):