From 09111dc52928ad47b7958a42f54421bee4673ad8 Mon Sep 17 00:00:00 2001 From: Patrick Delcroix Date: Mon, 2 May 2022 11:49:07 +0200 Subject: [PATCH 01/20] Update 0015_missing_roles.py --- core/migrations/0015_missing_roles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/migrations/0015_missing_roles.py b/core/migrations/0015_missing_roles.py index 53e8d4bb..477a59d9 100644 --- a/core/migrations/0015_missing_roles.py +++ b/core/migrations/0015_missing_roles.py @@ -7,7 +7,7 @@ logger = logging.getLogger(__name__) ROLE_RIGHTS_ID = [101201, 101101, 101001, 101105] -MANAGER_ROLE_IS_SYSTEM = 256 # By default missing rights are assigned to ClaimAdmin role +MANAGER_ROLE_IS_SYSTEM = 64 # By default missing rights are assigned to ClaimAdmin role @lru_cache(maxsize=1) From 323446cc11a846570cbeb6f50454ba4160177671 Mon Sep 17 00:00:00 2001 From: sniedzielski Date: Thu, 5 May 2022 14:23:54 +0200 Subject: [PATCH 02/20] OP-765: fixed RoleRights - 'pagination' and 'validity_to' issues --- core/schema.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/core/schema.py b/core/schema.py index 09e98251..2f8fa1bc 100644 --- a/core/schema.py +++ b/core/schema.py @@ -381,7 +381,7 @@ class Query(graphene.ObjectType): ) role_right = OrderedDjangoFilterConnectionField( - RoleRightGQLType, orderBy=graphene.List(of_type=graphene.String), validity=graphene.Date() + RoleRightGQLType, orderBy=graphene.List(of_type=graphene.String), validity=graphene.Date(), max_limit=None ) interactiveUsers = OrderedDjangoFilterConnectionField( @@ -596,7 +596,12 @@ def resolve_role(self, info, **kwargs): def resolve_role_right(self, info, **kwargs): if not info.context.user.has_perms(CoreConfig.gql_query_roles_perms): raise PermissionError("Unauthorized") - return gql_optimizer.query(RoleRight.objects.all(), info) + filters = [] + if 'validity' in kwargs: + filters += filter_validity(**kwargs) + return gql_optimizer.query(RoleRight.objects.filter(*filters), info) + else: + return gql_optimizer.query(RoleRight.objects.filter(validity_to__isnull=True), info) def resolve_modules_permissions(self, info, **kwargs): if not info.context.user.has_perms(CoreConfig.gql_query_roles_perms): @@ -611,8 +616,13 @@ def resolve_modules_permissions(self, info, **kwargs): config = [] for app in all_apps: apps = __import__(f"{app}.apps") - if hasattr(apps.apps, 'DEFAULT_CFG'): - config_dict = ModuleConfiguration.get_or_default(f"{app}", apps.apps.DEFAULT_CFG) + is_default_cfg = hasattr(apps.apps, 'DEFAULT_CFG') + is_defaulf_config = hasattr(apps.apps, 'DEFAULT_CONFIG') + if is_default_cfg or is_defaulf_config: + if is_defaulf_config: + config_dict = ModuleConfiguration.get_or_default(f"{app}", apps.apps.DEFAULT_CONFIG) + else: + config_dict = ModuleConfiguration.get_or_default(f"{app}", apps.apps.DEFAULT_CFG) permission = [] for key, value in config_dict.items(): if key.endswith("_perms"): From 2bbc034ee21b8d08724471f83b5fa27c2cf1bcf1 Mon Sep 17 00:00:00 2001 From: Patrick Delcroix Date: Sun, 8 May 2022 13:35:29 +0200 Subject: [PATCH 03/20] calculate_if_active_for_object add calculate_if_active_for_object function --- core/abs_calculation_rule.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/core/abs_calculation_rule.py b/core/abs_calculation_rule.py index 4fa87c81..763adec1 100644 --- a/core/abs_calculation_rule.py +++ b/core/abs_calculation_rule.py @@ -145,15 +145,22 @@ def run_calculation_rules(cls, sender, instance, user, context, **kwargs): reply if they have object matching the classname in their list of object """ list_class = cls.get_linked_class(sender=sender, class_name=instance.__class__.__name__) + # if the class have a calculation param, (like contribution or payment plan) add class name + if hasattr(instance,'calculation'): + list_class.append(instance.__class__.__name__) if list_class: - if len(list_class) > 0: - rule_details = cls.get_rule_details(class_name=list_class[0], sender=sender) + for class_name in list_class: + rule_details = cls.get_rule_details(class_name=class_name, sender=sender) if rule_details or len(cls.impacted_class_parameter) == 0: - if cls.active_for_object(instance=instance, context=context): - # add context to kwargs - kwargs["context"] = context - result = cls.calculate(instance, **kwargs) - return result + # add context to kwargs + kwargs["context"] = context + result = cls.calculate_if_active_for_object(instance, context, **kwargs) + return result + + @classmethod + def calculate_if_active_for_object(cls, instance, context, **kwargs): + if cls.active_for_object(instance=instance, context=context): + return cls.calculate(instance, context=context, **kwargs) @classmethod def run_convert(cls, instance, convert_to, **kwargs): From d51844d458e605de897c6d9dc6b35f74d622bd3a Mon Sep 17 00:00:00 2001 From: sniedzielski Date: Mon, 9 May 2022 13:41:37 +0200 Subject: [PATCH 04/20] OP-759: fix active for object method and kwargs --- core/abs_calculation_rule.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/abs_calculation_rule.py b/core/abs_calculation_rule.py index 763adec1..38ebd393 100644 --- a/core/abs_calculation_rule.py +++ b/core/abs_calculation_rule.py @@ -146,7 +146,7 @@ def run_calculation_rules(cls, sender, instance, user, context, **kwargs): """ list_class = cls.get_linked_class(sender=sender, class_name=instance.__class__.__name__) # if the class have a calculation param, (like contribution or payment plan) add class name - if hasattr(instance,'calculation'): + if hasattr(instance, 'calculation'): list_class.append(instance.__class__.__name__) if list_class: for class_name in list_class: @@ -154,13 +154,13 @@ def run_calculation_rules(cls, sender, instance, user, context, **kwargs): if rule_details or len(cls.impacted_class_parameter) == 0: # add context to kwargs kwargs["context"] = context - result = cls.calculate_if_active_for_object(instance, context, **kwargs) + result = cls.calculate_if_active_for_object(instance, **kwargs) return result @classmethod - def calculate_if_active_for_object(cls, instance, context, **kwargs): - if cls.active_for_object(instance=instance, context=context): - return cls.calculate(instance, context=context, **kwargs) + def calculate_if_active_for_object(cls, instance, **kwargs): + if cls.active_for_object(instance=instance, context=kwargs['context']): + return cls.calculate(instance, **kwargs) @classmethod def run_convert(cls, instance, convert_to, **kwargs): From 2fce4c0c93a6855cf82e2b93d232768b662407b3 Mon Sep 17 00:00:00 2001 From: Eric Darchis Date: Thu, 12 May 2022 08:57:54 +0200 Subject: [PATCH 05/20] Add user auto provisioning to JWT auth --- core/schema.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/core/schema.py b/core/schema.py index 2f8fa1bc..76a13c0b 100644 --- a/core/schema.py +++ b/core/schema.py @@ -8,6 +8,7 @@ from datetime import datetime as py_datetime from functools import reduce from django.utils.translation import gettext_lazy +from graphql_jwt.mutations import JSONWebTokenMutation, mixins import graphene_django_optimizer as gql_optimizer from core.services import ( create_or_update_interactive_user, @@ -1213,6 +1214,20 @@ def mutate_and_get_payload(cls, root, info, username, token, new_password, **inp ) +class OpenimisObtainJSONWebToken(mixins.ResolveMixin, JSONWebTokenMutation): + """Obtain JSON Web Token mutation, with auto-provisioning from tblUsers """ + @classmethod + def mutate(cls, root, info, **kwargs): + username = kwargs.get("username") + # consider auto-provisioning + if username: + # get_or_create will auto-provision from tblUsers if applicable + user = User.objects.get_or_create(username=username) + if user: + logger.debug("Authentication with %s failed and could not be fetched from tblUsers", username) + return super().mutate(cls, info, **kwargs) + + class Mutation(graphene.ObjectType): create_role = CreateRoleMutation.Field() update_role = UpdateRoleMutation.Field() @@ -1227,7 +1242,7 @@ class Mutation(graphene.ObjectType): reset_password = ResetPasswordMutation.Field() set_password = SetPasswordMutation.Field() - token_auth = graphql_jwt.mutations.ObtainJSONWebToken.Field() + token_auth = OpenimisObtainJSONWebToken.Field() verify_token = graphql_jwt.mutations.Verify.Field() refresh_token = graphql_jwt.mutations.Refresh.Field() revoke_token = graphql_jwt.mutations.Revoke.Field() From e6ce0e331c2398b382dfc1ba0fbc936b8581c844 Mon Sep 17 00:00:00 2001 From: Damian Borowiecki Date: Thu, 14 Jul 2022 17:05:40 +0200 Subject: [PATCH 06/20] OP-811: Validity filtering for user districts --- core/gql_queries.py | 6 ++++++ core/scheduler.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/core/gql_queries.py b/core/gql_queries.py index a2a46670..2827cf85 100644 --- a/core/gql_queries.py +++ b/core/gql_queries.py @@ -123,6 +123,12 @@ def resolve_roles(self, info, **kwargs): else: return None + def resolve_userdistrict_set(self, info, **kwargs): + if self.userdistrict_set: + return self.userdistrict_set.filter(*filter_validity()) + else: + return None + @classmethod def get_queryset(cls, queryset, info): return InteractiveUser.get_queryset(queryset, info) diff --git a/core/scheduler.py b/core/scheduler.py index cf85cfb7..848cfe43 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -4,13 +4,14 @@ # from apscheduler.executors.pool import ProcessPoolExecutor, ThreadPoolExecutor from core import get_scheduler_method_ref from django_apscheduler.jobstores import register_events # , register_job +from copy import deepcopy from django.conf import settings logger = logging.getLogger(__name__) # Create scheduler to run in a thread inside the application process -scheduler = BackgroundScheduler(settings.SCHEDULER_CONFIG) +scheduler = BackgroundScheduler(deepcopy(settings.SCHEDULER_CONFIG)) def schedule_tasks(task_scheduler): From ef314df257a3767d95132ab766b552d174f273ea Mon Sep 17 00:00:00 2001 From: Damian Borowiecki Date: Thu, 14 Jul 2022 17:26:56 +0200 Subject: [PATCH 07/20] OTC-621: Added README entry for scheduled tasks implementation --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 9882137d..1fa8ed47 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,27 @@ When running the application, the modules are searched for the `bind_service_sig function in the module_name/signals.py directory. This method should use `core.signals.bind_service_signal` function to connect new signals. Receivers can be registered also in other places. +#### Modules Scheduled Tasks +To add a scheduled task directly from within a module, add the file `scheduled_tasks.py` +in the module package. From there, the function `schedule_tasks` accepting `BackgroundScheudler` +as argument must be accessible. + +**Example content of scheduled_tasks.py:** +```python +def module_task(): + ... + +def schedule_tasks(scheduler: BackgroundScheduler): # Has to accept BackgroundScheudler as input + scheduler.add_job( + module_task, + trigger=CronTrigger(hour=8), # Daily at 8 AM + id="custom_schedule_id", # Must be unique across application + max_instances=1 + ) +``` +Task will be automatically registered, but it will not be triggered unless `SCHEDULER_AUTOSTART` setting is +set to `True`. + ### Graphene Custom Types & Helper Classes/Methods * schema.SmallInt: Integer, with values ranging from -32768 to +32767 * schema.TinyInt: Integer (8 bit), with values ranging from 0 to 255 From 95280a14337112dac3a2127ca0c46501d53e1769 Mon Sep 17 00:00:00 2001 From: Eric Darchis Date: Wed, 10 Aug 2022 00:37:07 +0200 Subject: [PATCH 08/20] PostgreSQL support * Introduce insert_role_right_for_system that was used verbosely from various modules with TSQL blocks * Adapt migrations * Transfer some migrations RunSQL to the next migration step to avoid renumbering them but also avoid an error that the SQL referred a DDL change --- core/jwt.py | 1 - core/migrations/0012_users_officers_admins.py | 28 ------------- core/migrations/0013_users_api.py | 41 ++++++++++++++++++- ...0016_add_last_login_on_interactive_user.py | 8 ++-- core/utils.py | 18 ++++++++ 5 files changed, 63 insertions(+), 33 deletions(-) diff --git a/core/jwt.py b/core/jwt.py index 772ccef9..72097f3f 100644 --- a/core/jwt.py +++ b/core/jwt.py @@ -2,7 +2,6 @@ # from django.utils.deprecation import MiddlewareMixin import jwt from graphql_jwt.settings import jwt_settings -from django.apps import apps from graphql_jwt.signals import token_issued from django.apps import apps from django.utils import timezone diff --git a/core/migrations/0012_users_officers_admins.py b/core/migrations/0012_users_officers_admins.py index fc9e5ebe..b74993fc 100644 --- a/core/migrations/0012_users_officers_admins.py +++ b/core/migrations/0012_users_officers_admins.py @@ -38,32 +38,4 @@ class Migration(migrations.Migration): }, bases=(models.Model, core.models.ObjectMutation), ), - migrations.RunSQL(sql=""" - insert into core_User (id, username, i_user_id, t_user_id, officer_id, claim_admin_id) - select replace(lower(newid()), '-', ''), o.code, u.UserID, null, max(o.OfficerID), null - from tblOfficer o - left join tblUsers u on o.code = u.LoginName - where not exists (select 1 from core_User where username=o.code) - and o.ValidityTo is null and u.ValidityTo is null - group by o.code, u.UserID; - update core_User - set officer_id=maxofficer.maxid - from core_User cu inner join - (select code, max(OfficerID) as maxid from tblOfficer where validityTo is null group by code) maxofficer - on code=cu.username - where cu.officer_id is null; - insert into core_User (id, username, i_user_id, t_user_id, officer_id, claim_admin_id) - select replace(lower(newid()), '-', '') as uuid, ca.ClaimAdminCode, u.UserID, null as t, null as o, max(ca.ClaimAdminId) as max_id - from tblClaimAdmin ca - left join tblUsers u on ca.ClaimAdminCode = u.LoginName - where not exists (select 1 from core_User where username=ca.ClaimAdminCode) - and ca.ValidityTo is null and u.ValidityTo is null - group by ca.ClaimAdminCode, u.UserID; - update core_User - set claim_admin_id=maxca.maxid - from core_User cu inner join - (select ClaimAdminCode, max(ClaimAdminId) as maxid from tblClaimAdmin where validityTo is null group by ClaimAdminCode) maxca - on ClaimAdminCode=cu.username - where cu.claim_admin_id is null; - """, reverse_sql="") ] diff --git a/core/migrations/0013_users_api.py b/core/migrations/0013_users_api.py index 0dd71755..48477723 100644 --- a/core/migrations/0013_users_api.py +++ b/core/migrations/0013_users_api.py @@ -1,8 +1,11 @@ # Generated by Django 3.0.14 on 2021-06-01 12:51 - +from django.conf import settings from django.db import migrations +NEWID_FUNC = "replace(lower(newid()), '-', '')" if "sql_server" in settings.DB_ENGINE else "gen_random_uuid()" + + class Migration(migrations.Migration): dependencies = [ @@ -15,4 +18,40 @@ class Migration(migrations.Migration): old_name='user', new_name='core_user', ), + # The following migrations are not related to users api but a continuation of the previous one + # It is necessary to support PostgreSQL without breaking existing setups + migrations.RunSQL(sql=f""" + insert into "core_User" (id, username, i_user_id, t_user_id, officer_id, claim_admin_id) + select {NEWID_FUNC}, o."Code", u."UserID", null, max(o."OfficerID"), null + from "tblOfficer" o + left join "tblUsers" u on o."Code" = u."LoginName" + where not exists (select 1 from "core_User" where username=o."Code") + and o."ValidityTo" is null and u."ValidityTo" is null + group by o."Code", u."UserID"; + """, reverse_sql=""), + migrations.RunSQL(sql=f""" + update "core_User" + set officer_id=maxofficer.maxid + from "core_User" cu inner join + (select "Code", max("OfficerID") as maxid from "tblOfficer" where "ValidityTo" is null group by "Code") maxofficer + on "Code"=cu.username + where cu.officer_id is null; + """, reverse_sql=""), + migrations.RunSQL(sql=f""" + insert into "core_User" (id, username, i_user_id, t_user_id, officer_id, claim_admin_id) + select {NEWID_FUNC} as uuid, ca."ClaimAdminCode", u."UserID", null as t, null as o, max(ca."ClaimAdminId") as max_id + from "tblClaimAdmin" ca + left join "tblUsers" u on ca."ClaimAdminCode" = u."LoginName" + where not exists (select 1 from "core_User" where username=ca."ClaimAdminCode") + and ca."ValidityTo" is null and u."ValidityTo" is null + group by ca."ClaimAdminCode", u."UserID"; + """, reverse_sql=""), + migrations.RunSQL(sql=f""" + update "core_User" + set claim_admin_id=maxca.maxid + from "core_User" cu inner join + (select "ClaimAdminCode", max("ClaimAdminId") as maxid from "tblClaimAdmin" where "ValidityTo" is null group by "ClaimAdminCode") maxca + on "ClaimAdminCode"=cu.username + where cu.claim_admin_id is null; + """, reverse_sql="") ] diff --git a/core/migrations/0016_add_last_login_on_interactive_user.py b/core/migrations/0016_add_last_login_on_interactive_user.py index 08132ee8..458cf931 100644 --- a/core/migrations/0016_add_last_login_on_interactive_user.py +++ b/core/migrations/0016_add_last_login_on_interactive_user.py @@ -1,5 +1,5 @@ # Generated by Django 3.0.14 on 2022-01-04 11:04 - +from django.conf import settings from django.db import migrations, models @@ -11,7 +11,9 @@ class Migration(migrations.Migration): operations = [ migrations.RunSQL( - "ALTER TABLE tblUsers ADD LastLogin [datetime] NULL", - reverse_sql="ALTER TABLE tblUsers DROP COLUMN LastLogin", + "ALTER TABLE tblUsers ADD LastLogin [datetime] NULL" + if "sql_server" in settings.DB_ENGINE else + 'ALTER TABLE "tblUsers" ADD "LastLogin" timestamp NULL', + reverse_sql='ALTER TABLE "tblUsers" DROP COLUMN "LastLogin"', ), ] diff --git a/core/utils.py b/core/utils.py index 10191491..d6a61e18 100644 --- a/core/utils.py +++ b/core/utils.py @@ -2,6 +2,11 @@ import graphene from django.db.models import Q from django.utils.translation import gettext as _ +import logging +from django.apps import apps + + +logger = logging.getLogger(__file__) __all__ = [ "TimeUtils", @@ -232,3 +237,16 @@ def get_first_or_default_language(): return sorted_languages.order_by('sort_order').first() else: return Language.objects.first() + + +def insert_role_right_for_system(system_role, right_id): + RoleRight = apps.get_model("core", "RoleRight") + Role = apps.get_model("core", "Role") + existing_role = Role.objects.filter(is_system=system_role).first() + if not existing_role: + logger.warning("Migration requested a role_right for system role %s but couldn't find that role", system_role) + role_right = RoleRight.objects.filter(role=existing_role, right_id=right_id).first() + if not role_right: + role_right = RoleRight.objects.create(role=existing_role, right_id=right_id) + + return role_right From 4f5570bfd345e437bd1b66c9216a2e2e7d035dfb Mon Sep 17 00:00:00 2001 From: Eric Darchis Date: Thu, 11 Aug 2022 16:19:16 +0200 Subject: [PATCH 09/20] Sort by random support on PostgreSQL --- core/schema.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/schema.py b/core/schema.py index 09e98251..f7b5c832 100644 --- a/core/schema.py +++ b/core/schema.py @@ -287,16 +287,18 @@ def _filter_order_by(cls, order_by: str) -> str: def orderBy(cls, qs, args): order = args.get('orderBy', None) if order: + random_expression = RawSQL("NEWID()", params=[]) \ + if "sql_server" in settings.DB_ENGINE else \ + RawSQL("RANDOM()", params=[]) if type(order) is str: if order == "?": - snake_order = RawSQL("NEWID()", params=[]) + snake_order = random_expression else: # due to https://github.com/advisories/GHSA-xpfp-f569-q3p2 we are aggressively filtering the orderBy snake_order = to_snake_case(cls._filter_order_by(order)) else: snake_order = [ - to_snake_case(cls._filter_order_by(o)) if o != "?" else RawSQL( - "NEWID()", params=[]) + to_snake_case(cls._filter_order_by(o)) if o != "?" else random_expression for o in order ] qs = qs.order_by(*snake_order) From 4d9bde8100880f1219b3079fd9961db8c7d9c62c Mon Sep 17 00:00:00 2001 From: Damian Borowiecki Date: Thu, 8 Sep 2022 17:45:42 +0200 Subject: [PATCH 10/20] OP-840: Added is_officer check to user --- core/models.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/core/models.py b/core/models.py index 6da35cca..f0918720 100644 --- a/core/models.py +++ b/core/models.py @@ -406,6 +406,11 @@ def health_facility(self): return hf_model.objects.filter(pk=self.health_facility_id).first() return None + @property + def is_officer(self): + return Officer.objects.filter( + code=self.username, has_login=True, validity_to__isnull=True).exists() + def set_password(self, raw_password): from hashlib import sha256 from secrets import token_hex @@ -685,6 +690,24 @@ def set_password(self, raw_password): def check_password(self, raw_password): return False + @property + def officer_allowed_locations(self): + """ + Returns uuid of all locations allowed for given officer + """ + from location.models import OfficerVillage + villages = OfficerVillage.objects\ + .filter(officer=self, validity_to__isnull=True) + all_allowed_uuids = [] + for village in villages: + allowed_uuids = [village.location.uuid] + parent = village.location.parent + while parent is not None: + allowed_uuids.append(parent.uuid) + parent = parent.parent + all_allowed_uuids.extend(allowed_uuids) + return all_allowed_uuids + @classmethod def get_queryset(cls, queryset, user): if isinstance(user, ResolveInfo): From d75a536c4cf32e898cfb97f3288fb4c983443411 Mon Sep 17 00:00:00 2001 From: Eric Darchis Date: Sun, 11 Sep 2022 23:15:09 +0200 Subject: [PATCH 11/20] Django 3.1+ support --- core/apps.py | 1 + core/custom_lookups.py | 8 +++----- core/migrations/0013_users_api.py | 2 +- .../migrations/0016_add_last_login_on_interactive_user.py | 2 +- core/models.py | 7 +++---- core/schema.py | 2 +- 6 files changed, 10 insertions(+), 12 deletions(-) diff --git a/core/apps.py b/core/apps.py index d348fca4..55b12ee6 100644 --- a/core/apps.py +++ b/core/apps.py @@ -49,6 +49,7 @@ class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.AutoField' # Django 3.1+ name = MODULE_NAME age_of_majority = 18 password_reset_template = "password_reset.txt" diff --git a/core/custom_lookups.py b/core/custom_lookups.py index 2d9b42b3..2d1d119e 100644 --- a/core/custom_lookups.py +++ b/core/custom_lookups.py @@ -1,10 +1,8 @@ -from django.db.models import Lookup +from django.db.models import Lookup, JSONField from django.db.models.lookups import Contains -from jsonfallback.fields import FallbackJSONField - -@FallbackJSONField.register_lookup +@JSONField.register_lookup class JsonContains(Lookup): lookup_name = 'jsoncontains' @@ -43,7 +41,7 @@ def _build_sql_params(self, entity, json_conditions, ): return conditions -@FallbackJSONField.register_lookup +@JSONField.register_lookup class JsonContainsKey(Contains): lookup_name = 'jsoncontainskey' diff --git a/core/migrations/0013_users_api.py b/core/migrations/0013_users_api.py index 48477723..5a83a655 100644 --- a/core/migrations/0013_users_api.py +++ b/core/migrations/0013_users_api.py @@ -3,7 +3,7 @@ from django.db import migrations -NEWID_FUNC = "replace(lower(newid()), '-', '')" if "sql_server" in settings.DB_ENGINE else "gen_random_uuid()" +NEWID_FUNC = "replace(lower(newid()), '-', '')" if settings.MSSQL else "gen_random_uuid()" class Migration(migrations.Migration): diff --git a/core/migrations/0016_add_last_login_on_interactive_user.py b/core/migrations/0016_add_last_login_on_interactive_user.py index 458cf931..02d29fc1 100644 --- a/core/migrations/0016_add_last_login_on_interactive_user.py +++ b/core/migrations/0016_add_last_login_on_interactive_user.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): operations = [ migrations.RunSQL( "ALTER TABLE tblUsers ADD LastLogin [datetime] NULL" - if "sql_server" in settings.DB_ENGINE else + if settings.MSSQL else 'ALTER TABLE "tblUsers" ADD "LastLogin" timestamp NULL', reverse_sql='ALTER TABLE "tblUsers" DROP COLUMN "LastLogin"', ), diff --git a/core/models.py b/core/models.py index f0918720..3431913d 100644 --- a/core/models.py +++ b/core/models.py @@ -13,10 +13,9 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin, Group from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError from django.db import models -from django.db.models import Q, DO_NOTHING, F +from django.db.models import Q, DO_NOTHING, F, JSONField from django.utils.crypto import salted_hmac from graphql import ResolveInfo -from jsonfallback.fields import FallbackJSONField from simple_history.models import HistoricalRecords import core @@ -93,7 +92,7 @@ class Meta: class ExtendableModel(models.Model): - json_ext = FallbackJSONField( + json_ext = JSONField( db_column='JsonExt', blank=True, null=True) class Meta: @@ -843,7 +842,7 @@ class HistoryModel(DirtyFieldsMixin, models.Model): id = models.UUIDField(primary_key=True, db_column="UUID", default=None, editable=False) objects = HistoryModelManager() is_deleted = models.BooleanField(db_column="isDeleted", default=False) - json_ext = FallbackJSONField(db_column="Json_ext", blank=True, null=True) + json_ext = models.JSONField(db_column="Json_ext", blank=True, null=True) date_created = DateTimeField(db_column="DateCreated", null=True) date_updated = DateTimeField(db_column="DateUpdated", null=True) user_created = models.ForeignKey(User, db_column="UserCreatedUUID", related_name='%(class)s_user_created', diff --git a/core/schema.py b/core/schema.py index 5fb46bc0..c383aeb7 100644 --- a/core/schema.py +++ b/core/schema.py @@ -289,7 +289,7 @@ def orderBy(cls, qs, args): order = args.get('orderBy', None) if order: random_expression = RawSQL("NEWID()", params=[]) \ - if "sql_server" in settings.DB_ENGINE else \ + if settings.MSSQL else \ RawSQL("RANDOM()", params=[]) if type(order) is str: if order == "?": From 733209498205b06aa1897afbe59425ee8add2914 Mon Sep 17 00:00:00 2001 From: Damian Borowiecki Date: Wed, 21 Sep 2022 16:37:58 +0200 Subject: [PATCH 12/20] OP-847: Added searching by system id for roles --- core/gql_queries.py | 2 ++ core/schema.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/core/gql_queries.py b/core/gql_queries.py index 2827cf85..edeba565 100644 --- a/core/gql_queries.py +++ b/core/gql_queries.py @@ -32,6 +32,8 @@ def get_queryset(cls, queryset, info): class RoleGQLType(DjangoObjectType): + system_role_id = graphene.Int() + class Meta: model = Role interfaces = (graphene.relay.Node,) diff --git a/core/schema.py b/core/schema.py index 5fb46bc0..0d077d76 100644 --- a/core/schema.py +++ b/core/schema.py @@ -378,6 +378,7 @@ class Query(graphene.ObjectType): RoleGQLType, orderBy=graphene.List(of_type=graphene.String), is_system=graphene.Boolean(), + system_role_id=graphene.Int(), show_history=graphene.Boolean(), client_mutation_id=graphene.String(), str=graphene.String(description="Text search on any field") @@ -594,6 +595,10 @@ def resolve_role(self, info, **kwargs): query = query.filter( is_system=0 ) + + if system_role_id := kwargs.get('system_role_id', None): + query = query.filter(is_system=system_role_id) + return gql_optimizer.query(query.filter(*filters), info) def resolve_role_right(self, info, **kwargs): @@ -677,6 +682,8 @@ class RoleBase: # field to save all chosen rights to the role rights_id = graphene.List(graphene.Int, required=False) + system_role_id = graphene.Int(required=False) + def update_or_create_role(data, user): client_mutation_id = data.get("client_mutation_id", None) From 26ab654320b5159792bf86b9525d0acef15e6e55 Mon Sep 17 00:00:00 2001 From: Eric Darchis Date: Mon, 3 Oct 2022 18:01:36 +0200 Subject: [PATCH 13/20] Version updates in setup.py --- setup.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 48ae7bac..07eca823 100644 --- a/setup.py +++ b/setup.py @@ -31,12 +31,14 @@ classifiers=[ 'Environment :: Web Environment', 'Framework :: Django', - 'Framework :: Django :: 3.0', + 'Framework :: Django :: 3.1', + 'Framework :: Django :: 3.2', 'Intended Audience :: Developers', 'License :: OSI Approved :: GNU Affero General Public License v3', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', ], ) From 54639f499858d2572b84fc71292f12ad8af55730 Mon Sep 17 00:00:00 2001 From: Damian Borowiecki Date: Mon, 10 Oct 2022 09:49:50 +0200 Subject: [PATCH 14/20] OP-861: Field contol added --- core/apps.py | 9 +++ core/forms.py | 6 +- core/schema.py | 16 +++++ core/services/userServices.py | 2 + core/signals.py | 4 +- core/validation/obligatoryFieldValidation.py | 72 ++++++++++++++++++++ 6 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 core/validation/obligatoryFieldValidation.py diff --git a/core/apps.py b/core/apps.py index 55b12ee6..f55f6b63 100644 --- a/core/apps.py +++ b/core/apps.py @@ -45,6 +45,8 @@ "gql_mutation_create_claim_administrator_perms": ["121602"], "gql_mutation_update_claim_administrator_perms": ["121603"], "gql_mutation_delete_claim_administrator_perms": ["121604"], + "fields_controls_user": {}, + "fields_controls_eo": {}, } @@ -75,6 +77,9 @@ class CoreConfig(AppConfig): gql_mutation_update_claim_administrator_perms = [] gql_mutation_delete_claim_administrator_perms = [] + fields_controls_user = {} + fields_controls_eo = {} + def _import_module(self, cfg, k): logger.info('import %s.%s' % (cfg["%s_module" % k], cfg["%s_package" % k])) @@ -142,6 +147,10 @@ def _configure_permissions(self, cfg): CoreConfig.gql_mutation_create_claim_administrator_perms = cfg["gql_mutation_create_claim_administrator_perms"] CoreConfig.gql_mutation_update_claim_administrator_perms = cfg["gql_mutation_update_claim_administrator_perms"] CoreConfig.gql_mutation_delete_claim_administrator_perms = cfg["gql_mutation_delete_claim_administrator_perms"] + CoreConfig.gql_mutation_delete_claim_administrator_perms = cfg["gql_mutation_delete_claim_administrator_perms"] + + CoreConfig.fields_controls_user = cfg["fields_controls_user"] + CoreConfig.fields_controls_eo = cfg["fields_controls_eo"] def ready(self): from .models import ModuleConfiguration diff --git a/core/forms.py b/core/forms.py index 16c640bb..b806f52e 100644 --- a/core/forms.py +++ b/core/forms.py @@ -7,6 +7,7 @@ User = get_user_model() + class TechnicalUserForm(forms.ModelForm): password = forms.CharField( strip=False, @@ -25,9 +26,11 @@ def save(self, commit=True): user.save() return user + class TechnicalUserAdmin(admin.ModelAdmin): form = TechnicalUserForm + class GroupAdminForm(forms.ModelForm): class Meta: model = Group @@ -52,6 +55,7 @@ def save(self, *args, **kwargs): self.save_m2m() return instance + class GroupAdmin(admin.ModelAdmin): form = GroupAdminForm - filter_horizontal = ['permissions'] \ No newline at end of file + filter_horizontal = ['permissions'] diff --git a/core/schema.py b/core/schema.py index 64e42413..6303d629 100644 --- a/core/schema.py +++ b/core/schema.py @@ -8,6 +8,7 @@ from datetime import datetime as py_datetime from functools import reduce from django.utils.translation import gettext_lazy +from graphene.types.generic import GenericScalar from graphql_jwt.mutations import JSONWebTokenMutation, mixins import graphene_django_optimizer as gql_optimizer from core.services import ( @@ -38,6 +39,7 @@ from .apps import CoreConfig from .gql_queries import * from .models import ModuleConfiguration, FieldControl, MutationLog, Language, RoleMutation, UserMutation +from .validation.obligatoryFieldValidation import validate_payload_for_obligatory_fields MAX_SMALLINT = 32767 MIN_SMALLINT = -32768 @@ -371,6 +373,9 @@ class Query(graphene.ObjectType): validity=graphene.String(), layer=graphene.String()) + user_obligatory_fields = GenericScalar() + eo_obligatory_fields = GenericScalar() + mutation_logs = OrderedDjangoFilterConnectionField( MutationLogGQLType, orderBy=graphene.List(of_type=graphene.String)) @@ -480,6 +485,16 @@ def resolve_user(self, info): return info.context.user return None + def resolve_user_obligatory_fields(self, info): + if info.context.user.is_authenticated: + return CoreConfig.fields_controls_user + return None + + def resolve_eo_obligatory_fields(self, info): + if info.context.user.is_authenticated: + return CoreConfig.fields_controls_eo + return None + def resolve_users(self, info, email=None, last_name=None, other_names=None, phone=None, role_id=None, roles=None, health_facility_id=None, region_id=None, district_id=None, municipality_id=None, birth_date_from=None, birth_date_to=None, user_types=None, language=None, @@ -1065,6 +1080,7 @@ def async_mutate(cls, user, **data): @transaction.atomic +@validate_payload_for_obligatory_fields(CoreConfig.fields_controls_user, 'data') def update_or_create_user(data, user): client_mutation_id = data.get("client_mutation_id", None) # client_mutation_label = data.get("client_mutation_label", None) diff --git a/core/services/userServices.py b/core/services/userServices.py index 8c1fcba8..eb50a12a 100644 --- a/core/services/userServices.py +++ b/core/services/userServices.py @@ -11,6 +11,7 @@ from core.apps import CoreConfig from core.models import User, InteractiveUser, Officer, UserRole +from core.validation.obligatoryFieldValidation import validate_payload_for_obligatory_fields logger = logging.getLogger(__file__) @@ -106,6 +107,7 @@ def create_or_update_officer_villages(officer, village_ids, audit_user_id): ) +@validate_payload_for_obligatory_fields(CoreConfig.fields_controls_eo, 'data') def create_or_update_officer(user_id, data, audit_user_id, connected): officer_fields = { "username": "code", diff --git a/core/signals.py b/core/signals.py index d292315c..133e157c 100644 --- a/core/signals.py +++ b/core/signals.py @@ -61,7 +61,7 @@ def create_policy(self, *args, **kwargs) def decorator(func): @functools.wraps(func) - def wrapper_do_twice(*args, **kwargs): + def wrapper_propagate_signal(*args, **kwargs): registered_signal = REGISTERED_SERVICE_SIGNALS.get(signal_name, None) if registered_signal is None: __raise_unregistered_signal_exception(signal_name) @@ -77,7 +77,7 @@ def wrapper_do_twice(*args, **kwargs): signal_call_args['result'] = out registered_signal.send_signal_after(sender=cls_, **signal_call_args) return out - return wrapper_do_twice + return wrapper_propagate_signal return decorator diff --git a/core/validation/obligatoryFieldValidation.py b/core/validation/obligatoryFieldValidation.py new file mode 100644 index 00000000..f4eabc2f --- /dev/null +++ b/core/validation/obligatoryFieldValidation.py @@ -0,0 +1,72 @@ +import functools +from inspect import getfullargspec + + +class ObligatoryFieldValidationError(Exception): + ... + + +class ObligatoryFieldValidation: + def __init__(self, obligatory_fields_list): + self.obligatory_field_list = obligatory_fields_list + + def validate_obligatory_fields(self, payload): + """ + Validate payload against obligatory field definitions. Payload have to share same fields as model in which + data is validated. Validation is done using obligatory_fields_list. If field from obligatory field list is + not provided as key in payload or is blank then ObligatoryFieldValidationError exception will be raised. + + :param payload: dictionary from which object will be created. + :return: None + """ + for field_name, control in self.obligatory_field_list.items(): + if control not in ('O', 'H', 'M'): + raise ObligatoryFieldValidationError( + F'Invalid configuration for field {field_name}, value {control}' + F'is not proper value. Allowed values are:' + F'- [O] - Optional\n' + F'- [H] - Hidden\n' + F'- [M] - Mandatory\n' + F'Contact Administrator to fix this.' + ) + if control == 'O': + return + elif control == 'H': + if payload.get(field_name): + raise ObligatoryFieldValidationError( + F'Field {field_name} is set as hidden, but payload provides value. ' + F'Does field {field_name} have default?') + elif control == 'M' and not (payload.get(field_name)): + raise ObligatoryFieldValidationError(F'Field {field_name} is mandatory, ' + F'but payload does not provide value') + + +def validate_payload_for_obligatory_fields(list_of_obligatory_fields, payload_arg='data'): + """ + Function decorator used for conveniently validate payload in functions. + It creates instance of ObligatoryFieldValidation using list_of_obligatory_fields argument as obligatory + characters and executes validation. + :param list_of_obligatory_fields: List of obligatory fields for designated payload. + :param payload_arg: Additional argument determining name of decorated function argument that provides payload. + :return: None + """ + + def decorator(func): + # Get index of payload arg in case it's not explicitly provided as kwarg. + argspec = getfullargspec(func) + argument_index = argspec.args.index(payload_arg) + + @functools.wraps(func) + def wrapper_validate_fields(*args, **kwargs): + try: + value = args[argument_index] + except IndexError: + # Payload passed as kwarg not arg + value = kwargs[payload_arg] + + validator = ObligatoryFieldValidation(list_of_obligatory_fields) + validator.validate_obligatory_fields(value) + out = func(*args, **kwargs) + return out + return wrapper_validate_fields + return decorator From 908e95753da79e4725de534027b2310d8ec5662a Mon Sep 17 00:00:00 2001 From: Kamil Malinowski Date: Tue, 11 Oct 2022 10:34:27 +0200 Subject: [PATCH 15/20] OTC-717 Added mutation_extensions to OpenIMISMutation --- core/schema.py | 62 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/core/schema.py b/core/schema.py index 64e42413..4bc7c25c 100644 --- a/core/schema.py +++ b/core/schema.py @@ -30,7 +30,7 @@ from django.db.models.expressions import RawSQL from django.http import HttpRequest from django.utils import translation -from graphene.utils.str_converters import to_snake_case +from graphene.utils.str_converters import to_snake_case, to_camel_case from graphene_django.filter import DjangoFilterConnectionField import graphql_jwt from typing import Optional @@ -101,6 +101,31 @@ def parse_literal(ast): return None +class ParsedJSONString(graphene.JSONString): + """ + This type automatically converts keys of json object between camel case (to be used in serialized strings) + and snake case (to fit Python objects). + """ + + @staticmethod + def parse_keys(input_dict, key_parser): + if isinstance(input_dict, dict): + return {key_parser(k): ParsedJSONString.parse_keys(v, key_parser) if isinstance(v, dict) else v for k, v in + input_dict.items()} + + @staticmethod + def serialize(dt): + return ParsedJSONString.parse_keys(graphene.JSONString.serialize(dt), to_camel_case) + + @staticmethod + def parse_literal(node): + return ParsedJSONString.parse_keys(graphene.JSONString.parse_literal(node), to_snake_case) + + @staticmethod + def parse_value(value): + return ParsedJSONString.parse_keys(graphene.JSONString.parse_value(value), to_snake_case) + + class OpenIMISJSONEncoder(DjangoJSONEncoder): def default(self, o): if isinstance(o, HttpRequest): @@ -141,6 +166,8 @@ class Meta: class Input: client_mutation_label = graphene.String(max_length=255, required=False) client_mutation_details = graphene.List(graphene.String) + mutation_extensions = ParsedJSONString( + description="Extension data to be used by signals. Will not be pushed to mutation implementation.") @classmethod def async_mutate(cls, user, **data) -> Optional[str]: @@ -172,10 +199,10 @@ def mutate_and_get_payload(cls, root, info, **data): mutation_log.client_mutation_label, ) if ( - info - and info.context - and info.context.user - and not info.context.user.is_anonymous + info + and info.context + and info.context.user + and not info.context.user.is_anonymous ): lang = info.context.user.language if isinstance(lang, Language): @@ -225,9 +252,11 @@ def mutate_and_get_payload(cls, root, info, **data): else: logger.debug("[OpenIMISMutation %s] mutating...", mutation_log.id) try: + mutation_data = data.copy() + mutation_data.pop("mutation_extensions", None) error_messages = cls.async_mutate( info.context.user if info.context and info.context.user else None, - **data) + **mutation_data) if not error_messages: logger.debug("[OpenIMISMutation %s] marked as successful", mutation_log.id) mutation_log.mark_as_successful() @@ -358,11 +387,11 @@ def get_queryset(cls, queryset, info): UT_CLAIM_ADMIN = "CLAIM_ADMIN" UserTypeEnum = graphene.Enum("UserTypes", [ - (UT_INTERACTIVE, UT_INTERACTIVE), - (UT_OFFICER, UT_OFFICER), - (UT_TECHNICAL, UT_TECHNICAL), - (UT_CLAIM_ADMIN, UT_CLAIM_ADMIN) - ]) + (UT_INTERACTIVE, UT_INTERACTIVE), + (UT_OFFICER, UT_OFFICER), + (UT_TECHNICAL, UT_TECHNICAL), + (UT_CLAIM_ADMIN, UT_CLAIM_ADMIN) +]) class Query(graphene.ObjectType): @@ -443,7 +472,7 @@ def resolve_enrolment_officers(self, info, **kwargs): from .models import Officer if not info.context.user.has_perms( - CoreConfig.gql_query_enrolment_officers_perms + CoreConfig.gql_query_enrolment_officers_perms ): raise PermissionError("Unauthorized") @@ -891,7 +920,7 @@ def async_mutate(cls, user, **data): errors.append({ 'title': role, 'list': [{'message': - "role.validation.id_does_not_exist" % {'id': role_uuid}}] + "role.validation.id_does_not_exist" % {'id': role_uuid}}] }) continue errors += set_role_deleted(role) @@ -1055,7 +1084,7 @@ def async_mutate(cls, user, **data): errors.append({ 'title': user, 'list': [{'message': - "user.validation.id_does_not_exist" % {'id': user_uuid}}] + "user.validation.id_does_not_exist" % {'id': user_uuid}}] }) continue errors += set_user_deleted(user) @@ -1130,7 +1159,7 @@ class Input: username = graphene.String( required=False, description="By default, this operation works on the logged user," - "only administrators can run it on any user", + "only administrators can run it on any user", ) old_password = graphene.String( required=False, @@ -1143,7 +1172,7 @@ class Input: @classmethod def mutate_and_get_payload( - cls, root, info, new_password, old_password=None, username=None, **input + cls, root, info, new_password, old_password=None, username=None, **input ): try: user = info.context.user @@ -1225,6 +1254,7 @@ def mutate_and_get_payload(cls, root, info, username, token, new_password, **inp class OpenimisObtainJSONWebToken(mixins.ResolveMixin, JSONWebTokenMutation): """Obtain JSON Web Token mutation, with auto-provisioning from tblUsers """ + @classmethod def mutate(cls, root, info, **kwargs): username = kwargs.get("username") From cbeb4ec4619a3c0f8ca64f6e72fdbfe8f293dcbf Mon Sep 17 00:00:00 2001 From: Kamil Malinowski Date: Thu, 13 Oct 2022 14:37:36 +0200 Subject: [PATCH 16/20] OTC-717 Added readme for mutationExtensions --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 1fa8ed47..e52e94e6 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,10 @@ If the callback returns None (or an empty array), the mutation is marked as succ __Important Note__: by default the callback is executed __in transaction__ and, as a consequence, will (in case of exception/errors) cancel the complete mutation. If this is not the desired behaviour, the callback must explicitely detach to separate transaction (process). +#### Extending mutations with signals +Signal callbacks could use mutationExtensions JSON field to receive additional data from mutation payload. This +feature allows to extend mutations with a new module without modifying the base mutation. + #### Service signals In addition, the core provides the possibility to register additional signals via the `register_service_signal` decorator. Registered signals are stored in From 1eb353a6066731fc88709c9b6f4d5df6fcbc8983 Mon Sep 17 00:00:00 2001 From: Eric Darchis Date: Fri, 14 Oct 2022 10:39:59 +0200 Subject: [PATCH 17/20] Quick fix for Django 3.2 email field --- core/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/models.py b/core/models.py index 3431913d..7efee400 100644 --- a/core/models.py +++ b/core/models.py @@ -440,6 +440,10 @@ def is_interactive_user(cls, user): else: return None + @classmethod + def get_email_field_name(cls): + return "email" + @classmethod def get_queryset(cls, queryset, user): if isinstance(user, ResolveInfo): From 6eae1a93f03216a0ab9c9c6abfe5b2262e98b051 Mon Sep 17 00:00:00 2001 From: Kamil Malinowski Date: Fri, 14 Oct 2022 11:06:00 +0200 Subject: [PATCH 18/20] OP-868 Fixed CI issues --- core/calendars/test_ad_calendar.py | 4 +--- core/calendars/test_ne_calendar.py | 6 +++--- core/datetimes/test_ad_datetime.py | 2 ++ core/datetimes/test_ne_datetime.py | 2 ++ core/test_services.py | 1 + 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/core/calendars/test_ad_calendar.py b/core/calendars/test_ad_calendar.py index cb2b2148..d7fdc743 100644 --- a/core/calendars/test_ad_calendar.py +++ b/core/calendars/test_ad_calendar.py @@ -1,13 +1,11 @@ -import sys import importlib import core from django.test import TestCase -from ..datetimes.ad_datetime import date -from .ad_calendar import * class CalendarTestCase(TestCase): def setUp(self): + super(CalendarTestCase, self).setUp() core.calendar = importlib.import_module( '.calendars.ad_calendar', 'core') core.datetime = importlib.import_module( diff --git a/core/calendars/test_ne_calendar.py b/core/calendars/test_ne_calendar.py index cce37be2..73444b12 100644 --- a/core/calendars/test_ne_calendar.py +++ b/core/calendars/test_ne_calendar.py @@ -1,4 +1,3 @@ -import sys import importlib import core from django.test import TestCase @@ -7,6 +6,7 @@ class CalendarTestCase(TestCase): def setUp(self): + super(CalendarTestCase, self).setUp() core.calendar = importlib.import_module( '.calendars.ne_calendar', 'core') core.datetime = importlib.import_module( @@ -16,7 +16,7 @@ def tearDown(self): core.calendar = importlib.import_module( '.calendars.ad_calendar', 'core') core.datetime = importlib.import_module( - '.datetimes.ad_datetime', 'core') + '.datetimes.ad_datetime', 'core') def test_from_ad_date(self): dt = core.datetime.date.from_ad_date(py_date(2020, 1, 13)) @@ -36,7 +36,7 @@ def test_weekday(self): wd = core.calendar.weekday(2076, 9, 11) self.assertEqual(wd, 5) wd = core.calendar.weekday(2076, 9, 28) - self.assertEqual(wd, 1) + self.assertEqual(wd, 1) def test_monthfirstday(self): dt = core.calendar.monthfirstday(2076, 9) diff --git a/core/datetimes/test_ad_datetime.py b/core/datetimes/test_ad_datetime.py index 7217bc54..26c595eb 100644 --- a/core/datetimes/test_ad_datetime.py +++ b/core/datetimes/test_ad_datetime.py @@ -9,6 +9,7 @@ class SharedUtilsTest(TestCase): def setUp(self): + super(SharedUtilsTest, self).setUp() core.calendar = importlib.import_module( '.calendars.ad_calendar', 'core') core.datetime = importlib.import_module( @@ -148,6 +149,7 @@ def test_diff(self): class AdDatetimeTestCase(TestCase): def setUp(self): + super(AdDatetimeTestCase, self).setUp() core.calendar = importlib.import_module( '.calendars.ad_calendar', 'core') core.datetime = importlib.import_module( diff --git a/core/datetimes/test_ne_datetime.py b/core/datetimes/test_ne_datetime.py index 31bcbc2c..f1ae5a24 100644 --- a/core/datetimes/test_ne_datetime.py +++ b/core/datetimes/test_ne_datetime.py @@ -9,6 +9,7 @@ class NeDateTestCase(TestCase): def setUp(self): + super(NeDateTestCase, self).setUp() core.calendar = importlib.import_module( '.calendars.ne_calendar', 'core') core.datetime = importlib.import_module( @@ -163,6 +164,7 @@ def test_diff(self): class NeDatetimeTestCase(TestCase): def setUp(self): + super(NeDatetimeTestCase, self).setUp() core.calendar = importlib.import_module( '.calendars.ne_calendar', 'core') core.datetime = importlib.import_module( diff --git a/core/test_services.py b/core/test_services.py index 4e7f748c..539c460c 100644 --- a/core/test_services.py +++ b/core/test_services.py @@ -23,6 +23,7 @@ class UserServicesTest(TestCase): claim_admin_class = None def setUp(self): + super(UserServicesTest, self).setUp() # This shouldn't be necessary but cleanup from date tests tend not to cleanup properly core.calendar = importlib.import_module(".calendars.ad_calendar", "core") core.datetime = importlib.import_module(".datetimes.ad_datetime", "core") From 29541a8dff0749fbaa468e4236627395dd034634 Mon Sep 17 00:00:00 2001 From: Eric Darchis Date: Fri, 14 Oct 2022 11:59:43 +0200 Subject: [PATCH 19/20] Fix obscure template issue --- core/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/models.py b/core/models.py index 7efee400..fde72f5c 100644 --- a/core/models.py +++ b/core/models.py @@ -578,6 +578,10 @@ def __getattr__(self, name): def __call__(self, *args, **kwargs): # if not self._u: # raise ValueError('wrapper has not been initialised') + if len(args) == 0 and len(kwargs) == 0 and not callable(self._u): + # This happens when doing callable(user). Since this is a method, the class looks callable but it is not + # To avoid this, we'll just return the object when calling it. This avoid issues in Django templates + return self return self._u(*args, **kwargs) def __str__(self): From 97f773c1894c26645e4622e04d425def1fc6c6c8 Mon Sep 17 00:00:00 2001 From: Kamil Malinowski Date: Wed, 19 Oct 2022 16:47:38 +0200 Subject: [PATCH 20/20] OP-701 Added check permissions service helper --- core/services/utils/serviceUtils.py | 18 ++++++++++++++++++ core/test_services.py | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/core/services/utils/serviceUtils.py b/core/services/utils/serviceUtils.py index 64aa751c..cb407df4 100644 --- a/core/services/utils/serviceUtils.py +++ b/core/services/utils/serviceUtils.py @@ -22,6 +22,24 @@ def wrapper(self, *args, **kwargs): return wrapper +def check_permissions(permissions=None): + def decorator(function): + def wrapper(self, *args, **kwargs): + if not self.user.has_perms(permissions): + return { + "success": False, + "message": "Permissions required", + "detail": "PermissionDenied", + } + else: + result = function(self, *args, **kwargs) + return result + + return wrapper + + return decorator + + def model_representation(model): uuid_string = str(model.id) dict_representation = model_to_dict(model) diff --git a/core/test_services.py b/core/test_services.py index 539c460c..116ff9d4 100644 --- a/core/test_services.py +++ b/core/test_services.py @@ -228,7 +228,7 @@ def test_officer_min(self): officer, created = create_or_update_officer( user_id=None, data=dict( - username=username, last_name="Last Name O1", other_names="Other 1 2 3" + username=username, last_name="Last Name O1", other_names="Other 1 2 3", phone="+12345678" ), audit_user_id=999, connected=False, @@ -238,6 +238,7 @@ def test_officer_min(self): self.assertEquals(officer.username, username) self.assertEquals(officer.last_name, "Last Name O1") self.assertEquals(officer.other_names, "Other 1 2 3") + self.assertEquals(officer.phone, "+12345678") deleted_officers = Officer.objects.filter(code=username).delete() logger.info(f"Deleted {deleted_officers} officers after test")