diff --git a/README.md b/README.md index 82f8494f..613feb2f 100644 --- a/README.md +++ b/README.md @@ -273,9 +273,9 @@ DJANGO_DEFAULT_FROM_EMAIL=fake@email.com - [x] [PR#22](https://github.com/xjlin0/attendees30/pull/22) can gathering generation automatic? - [x] [PR#23](https://github.com/xjlin0/attendees30/pull/23) sorting & grouping by server side processing - [x] data [db backup/restore](https://cookiecutter-django.readthedocs.io/en/latest/docker-postgres-backups.html) to survive new releases and migrations -- [ ] Add Attendee+ buttons in above pages should deduplicate before creation by providing existing names for users to choose +- [x] Add Attendee+ buttons in above pages should deduplicate before creation by providing existing names for users to choose - [x] [PR#24](https://github.com/xjlin0/attendees30/pull/24) fix self attendee page error - - [ ] from Attendee detail and attendee list page + - [x] [PR#28](https://github.com/xjlin0/attendees30/pull/28) from Attendee detail and attendee list page - [ ] AttendingMeet list (server side processing) - [ ] new attendance datagrid filtered by meets and date ranges - [ ] auto-generation of AttendingMeet by django-schedule with certain Past @@ -300,6 +300,7 @@ DJANGO_DEFAULT_FROM_EMAIL=fake@email.com - [ ] Export pdf - [ ] directory booklet - [ ] mail labels (avery template) or printing envelops + - [ ] Todo: 20210517 When creating FamilyAttendee, also auto create relationships among families such as siblings, etc - [ ] i18n Translation on model data, django-parler maybe? @@ -311,4 +312,15 @@ DJANGO_DEFAULT_FROM_EMAIL=fake@email.com - [ ] for ordinary users: -[ ] even as scheduler seeing other's attendee detail view, the joined meet doesn't show group name (i.e. Hagar cannot see Ishmael in "the Rock") + -[ ] If manager A checked "secret shared with you" for a model with "share secret with you" and uniq constrain such as Relationship, manager B can't see it (expected) and creating another relationship will fail due to uniq constrain (not expected). If relating uniq constraint, after manager B create the very same relationship, manager A will see duplicated relationship. + + +## Design decisions: + +
+ Click to expand all + +- [ ] for all users: + -[ ] show_secret in Relationship/Past are based on attendee id instead of user id, since secret relationship exists regardless of user accounts. For example, kid X bullied kid Y, and counsellors can configure kids parents & teachers to see it regardless of parent/teachers user accounts existence or switches. + -[ ] There is no decision on display notes/infos of Relationships/FamilyAttendee/Past. Ordinary end users can see their families, so role-dependent showing/hiding notes columns need designs, such as storing allowed columns in Menu.infos read by User.allowed_url_names()? Currently UI only expose separated sections on Past which conditionally show to ordinary end users.
diff --git a/attendees/persons/models/utility.py b/attendees/persons/models/utility.py index 608c0cea..eb642663 100644 --- a/attendees/persons/models/utility.py +++ b/attendees/persons/models/utility.py @@ -65,7 +65,7 @@ def attendee_infos(): @staticmethod def relationship_infos(): - return {"show_secret": {}, "comment": None, "body": None} + return {"show_secret": {}, "updated_by": {}, "comment": None, "body": None} @staticmethod def forever(): # 1923 years from now diff --git a/attendees/persons/serializers/family_attendee_serializer.py b/attendees/persons/serializers/family_attendee_serializer.py index 6619178b..a0ce67a9 100644 --- a/attendees/persons/serializers/family_attendee_serializer.py +++ b/attendees/persons/serializers/family_attendee_serializer.py @@ -1,12 +1,12 @@ from rest_framework import serializers from attendees.persons.models import FamilyAttendee, Family, Attendee -from attendees.persons.serializers import FamilySerializer, AttendeeSerializer +from attendees.persons.serializers import FamilySerializer class FamilyAttendeeSerializer(serializers.ModelSerializer): family = FamilySerializer(many=False) - attendee = AttendeeSerializer(many=False) + # attendee = AttendeeSerializer(many=False) class Meta: model = FamilyAttendee @@ -21,15 +21,16 @@ def create(self, validated_data): """ familyattendee_id = self._kwargs.get('data', {}).get('id') new_family = Family.objects.filter(pk=self._kwargs.get('data', {}).get('family', {}).get('id')).first() - new_attendee_data = validated_data.get('attendee', {}) + new_attendee_id = validated_data.get('attendee', {}) if new_family: validated_data['family'] = new_family - - if new_attendee_data: - attendee, attendee_created = Attendee.objects.update_or_create( - id=new_attendee_data.get('id'), - defaults=new_attendee_data, - ) + print("hi 27 here is validated_data: ", validated_data) + if new_attendee_id: + # attendee, attendee_created = Attendee.objects.update_or_create( + # id=new_attendee_data.get('id'), + # defaults=new_attendee_data, + # ) + attendee = Attendee.objects.get(pk=new_attendee_id) validated_data['attendee'] = attendee # Todo: 20210517 create relationships among families such as siblings, etc obj, created = FamilyAttendee.objects.update_or_create( @@ -44,19 +45,20 @@ def update(self, instance, validated_data): """ new_family = Family.objects.filter(pk=self._kwargs.get('data', {}).get('family', {}).get('id')).first() - new_attendee_data = validated_data.get('attendee', {}) - + new_attendee_id = validated_data.get('attendee', {}) + print("hi 49 here is validated_data: ", validated_data) if new_family: # instance.family = new_family validated_data['family'] = new_family # else: # validated_data['family'] = instance.family - if new_attendee_data: - attendee, attendee_created = Attendee.objects.update_or_create( - id=instance.attendee.id, - defaults=new_attendee_data, - ) + if new_attendee_id: + # attendee, attendee_created = Attendee.objects.update_or_create( + # id=instance.attendee.id, + # defaults=new_attendee_data, + # ) + attendee = Attendee.objects.get(pk=new_attendee_id) validated_data['attendee'] = attendee # else: # validated_data['attendee'] = instance.attendee diff --git a/attendees/persons/services/attendee_service.py b/attendees/persons/services/attendee_service.py index a3d3ae24..c3771076 100644 --- a/attendees/persons/services/attendee_service.py +++ b/attendees/persons/services/attendee_service.py @@ -104,22 +104,21 @@ def find_related_ones(current_user, target_attendee, querying_attendee_id, filte return target_attendee.related_ones.all() @staticmethod - def by_datagrid_params(current_user, meets, orderby_string, filters_list): + def by_datagrid_params(current_user, meets, orderby_string, filters_list, include_dead): """ :param current_user: :param meets: attendee participated assembly ids. Exception: if assembly is in organization's all_access_assemblies, all attendee of the same org will be return :param orderby_string: :param filters_list: + :param include_dead: :return: """ orderby_list = AttendeeService.orderby_parser(orderby_string, meets, current_user) + init_query_q = {'division__organization': current_user.organization, 'is_removed': False} + if not include_dead: + init_query_q['deathday__isnull'] = True - init_query = Q( - division__organization=current_user.organization, - deathday__isnull=True, - is_removed=False, - ) - # Todo: need filter on attending_meet finish_date + init_query = Q(**init_query_q) # Todo: need filter on attending_meet finish_date final_query = init_query.add(AttendeeService.filter_parser(filters_list, meets, current_user), Q.AND) qs = Attendee.objects if current_user.can_see_all_organizational_meets_attendees() else current_user.attendee.scheduling_attendees() @@ -259,8 +258,8 @@ def destroy_with_associations(attendee): old_file = Path(old_photo.path) old_file.unlink(missing_ok=True) - if hasattr(attendee, 'user'): - attendee_user = attendee.user + attendee_user = attendee.user + if attendee_user: attendee.delete() attendee_user.delete() else: diff --git a/attendees/persons/urls.py b/attendees/persons/urls.py index 2ff0b21c..a90f5f40 100644 --- a/attendees/persons/urls.py +++ b/attendees/persons/urls.py @@ -144,9 +144,9 @@ ), path( 'attendee/new', - kwargs={'attendee_id': 'new', 'show_create_nonfamily_attendee': False}, + kwargs={'attendee_id': 'new', 'show_create_attendee': False}, view=attendee_update_view, - name='attendee_create_view', # for create non-family-attendee permission + name='attendee_create_view', # for create attendee permission ), path( 'attendee/', diff --git a/attendees/persons/views/api/all_relations.py b/attendees/persons/views/api/all_relations.py index 409f28fe..703e8fb3 100644 --- a/attendees/persons/views/api/all_relations.py +++ b/attendees/persons/views/api/all_relations.py @@ -1,23 +1,30 @@ +import ast from django.contrib.auth.mixins import LoginRequiredMixin - +from django.db.models import Q from rest_framework import viewsets from attendees.persons.models import Relation from attendees.persons.serializers import RelationSerializer +from attendees.persons.services import AttendeeService class ApiAllRelationsViewsSet(LoginRequiredMixin, viewsets.ModelViewSet): """ - API endpoint that allows Relation(Role) to be viewed or edited. + API endpoint that allows Relation(Role) to be viewed or edited. It's public and not org limited. """ serializer_class = RelationSerializer def get_queryset(self): relation_id = self.request.query_params.get('relation_id') + filters_list_string = self.request.query_params.get('filter', '[]') + filters_list = ast.literal_eval(filters_list_string) # copied from ApiDatagridDataAttendeesViewSet + if relation_id: return Relation.objects.filter(pk=relation_id) else: - return Relation.objects.order_by('display_order') + init_query = Q(is_removed=False) + final_query = init_query.add(AttendeeService.filter_parser(filters_list, None, self.request.user), Q.AND) + return Relation.objects.filter(final_query).order_by('display_order') api_all_relations_viewset = ApiAllRelationsViewsSet diff --git a/attendees/persons/views/api/attendee_relationships.py b/attendees/persons/views/api/attendee_relationships.py index 6df27503..f977251c 100644 --- a/attendees/persons/views/api/attendee_relationships.py +++ b/attendees/persons/views/api/attendee_relationships.py @@ -1,5 +1,6 @@ import time from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Q from django.shortcuts import get_object_or_404 from rest_framework import viewsets from rest_framework.exceptions import PermissionDenied @@ -31,18 +32,91 @@ def get_queryset(self): target_attendee = get_object_or_404(Attendee, pk=self.request.META.get('HTTP_X_TARGET_ATTENDEE_ID')) target_relationship_id = self.kwargs.get('pk') + requester_permission = {'infos__show_secret__' + self.request.user.attendee_uuid_str(): True} + if target_relationship_id: return Relationship.objects.filter( - pk=target_relationship_id, - to_attendee__division__organization=target_attendee.division.organization, - is_removed=False, + Q(pk=target_relationship_id), + Q(to_attendee__division__organization=target_attendee.division.organization), + Q(is_removed=False), + (Q(infos__show_secret={}) + | + Q(infos__show_secret__isnull=True) + | + Q(**requester_permission)), ) else: return Relationship.objects.filter( - from_attendee=target_attendee, - to_attendee__division__organization=target_attendee.division.organization, - is_removed=False, + Q(from_attendee=target_attendee), + Q(to_attendee__division__organization=target_attendee.division.organization), + Q(is_removed=False), + (Q(infos__show_secret={}) + | + Q(infos__show_secret__isnull=True) + | + Q(**requester_permission)), ) + def perform_create(self, serializer): #SpyGuard ensured requester & target_attendee belongs to the same org. + """ + Reason for special create: + If manager A checked "secret shared with you" for a Relationship, manager B + can't see it (expected) and creating the same relationship will fail due to uniq + constrain (not expected). If relaxing uniq constraint, after manager B creating + the very same relationship, manager A will see duplicated relationship. Instead we + will add manager B id in secret shared with you of infos when manager B create it. + """ + current_user_attendee_id = self.request.user.attendee_uuid_str() + existing_relationship = Relationship.objects.filter( + from_attendee=serializer.validated_data['from_attendee'], + to_attendee=serializer.validated_data['to_attendee'], + relation=serializer.validated_data['relation'], + is_removed=False, + ).first() + if existing_relationship and existing_relationship.infos.get('show_secret'): # current manager can't see existing record & try to create one, means existing record must be secret + existing_relationship.infos['show_secret'][current_user_attendee_id] = True + existing_relationship.infos['updated_by'][current_user_attendee_id] = Utility.now_with_timezone().isoformat() + existing_relationship.save() # manager's new data is intentionally discarded to show previous data + else: + serializer.validated_data['infos']['updated_by'] = {current_user_attendee_id: Utility.now_with_timezone().isoformat()} + serializer.save() + + def perform_update(self, serializer): + """ + Reason for special update: + For a public relationship, if a manager add him/herself in secret shared with you of + infos, all updater should be added too, or updater won't see it. + + if manager A&B both in secret shared with you of infos, either manager can make it public. + """ + existing_relationship = get_object_or_404(Relationship, pk=self.kwargs.get('pk')) + if 'show_secret' in serializer.validated_data.get('infos', {}): + if serializer.validated_data['infos']['show_secret']: # user is checking "secret shared with you" + for updater_attendee_id in existing_relationship.infos.get('updated_by', {}): + serializer.validated_data['infos']['show_secret'][updater_attendee_id] = True + existing_relationship.infos['show_secret'][updater_attendee_id] = True + for new_attendee_id in serializer.validated_data['infos']['show_secret']: + existing_relationship.infos['show_secret'][new_attendee_id] = True + else: # user is unchecking "secret shared with you", making it public + existing_relationship.infos['show_secret'] = {} + serializer.validated_data['infos'] = existing_relationship.infos + serializer.validated_data['infos']['updated_by'][self.request.user.attendee_uuid_str()] = Utility.now_with_timezone().isoformat() + serializer.save() + + def perform_destroy(self, instance): + """ + Reason for special delete: + When both managers are in secret shared with you of infos, when manager A deletes + such records, it will only remove manager A from secret shared with you of infos. + """ + current_user_attendee_id = self.request.user.attendee_uuid_str() + instance.infos['updated_by'][current_user_attendee_id] = Utility.now_with_timezone().isoformat() + if len(instance.infos.get('show_secret', {})) > 1: # manager must able to see record before delete + del instance.infos['show_secret'][current_user_attendee_id] + instance.save() + else: + instance.save() + instance.delete() + api_attendee_relationships_viewset = ApiAttendeeRelationshipsViewSet diff --git a/attendees/persons/views/api/categorized_pasts.py b/attendees/persons/views/api/categorized_pasts.py index acfbac21..f1ceb6ca 100644 --- a/attendees/persons/views/api/categorized_pasts.py +++ b/attendees/persons/views/api/categorized_pasts.py @@ -51,6 +51,7 @@ def get_queryset(self): qs = target_attendee.pasts.filter( Q(organization=self.request.user.organization), Q(category__type=category__type), + Q(is_removed=False), ( Q(infos__show_secret={}) | Q(infos__show_secret__isnull=True) diff --git a/attendees/persons/views/api/datagrid_data_attendees.py b/attendees/persons/views/api/datagrid_data_attendees.py index 88287aa0..86a6537e 100644 --- a/attendees/persons/views/api/datagrid_data_attendees.py +++ b/attendees/persons/views/api/datagrid_data_attendees.py @@ -27,12 +27,13 @@ def get_queryset(self): orderby_string = self.request.query_params.get('sort', '[{"selector":"id","desc":false}]') # default order meets_string = self.request.query_params.get('meets', '[]') filters_list_string = self.request.query_params.get('filter', '[]') - + include_dead = self.request.query_params.get('include_dead') return AttendeeService.by_datagrid_params( current_user=self.request.user, meets=ast.literal_eval(meets_string), orderby_string=orderby_string, filters_list=ast.literal_eval(filters_list_string), + include_dead=include_dead, ) # Datagrid can't send array in standard url params since filters can be dynamic nested arrays diff --git a/attendees/persons/views/page/attendee_update_view.py b/attendees/persons/views/page/attendee_update_view.py index 83311164..19368fd8 100644 --- a/attendees/persons/views/page/attendee_update_view.py +++ b/attendees/persons/views/page/attendee_update_view.py @@ -1,13 +1,14 @@ -import time +from time import sleep +from json import dumps from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType from django.http import Http404 from django.shortcuts import render -from django.views.generic import DetailView, UpdateView +from django.views.generic import UpdateView -from attendees.occasions.models import Assembly from attendees.persons.models import Attendee, Family +from attendees.whereabouts.models import Division from attendees.users.authorization import RouteAndSpyGuard from attendees.users.models import Menu from attendees.utils.view_helpers import get_object_or_delayed_403 @@ -27,17 +28,13 @@ def get_object(self, queryset=None): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - # current_division_slug = self.kwargs.get('division_slug', None) - # current_organization_slug = self.kwargs.get('organization_slug', None) - # current_assembly_slug = self.kwargs.get('assembly_slug', 'cfcch_unspecified') - # current_assembly_id = Assembly.objects.get(slug=current_assembly_slug).id targeting_attendee_id = self.kwargs.get('attendee_id', self.request.user.attendee_uuid_str()) # if more logic needed when create new, a new view will be better - show_create_nonfamily_attendee = self.kwargs.get('show_create_nonfamily_attendee', Menu.user_can_create_attendee(self.request.user)) + show_create_attendee = self.kwargs.get('show_create_attendee', Menu.user_can_create_attendee(self.request.user)) context.update({ 'attendee_contenttype_id': ContentType.objects.get_for_model(Attendee).id, 'family_contenttype_id': ContentType.objects.get_for_model(Family).id, 'empty_image_link': f"{settings.STATIC_URL}images/empty.png", - 'show_create_nonfamily_attendee': show_create_nonfamily_attendee, + 'show_create_attendee': show_create_attendee, 'characters_endpoint': '/occasions/api/user_assembly_characters/', 'meets_endpoint': '/occasions/api/user_assembly_meets/', 'attendingmeets_endpoint': '/persons/api/datagrid_data_attendingmeet/', @@ -55,9 +52,8 @@ def get_context_data(self, **kwargs): 'attendings_endpoint': '/persons/api/attendee_attendings/', 'family_attendees_endpoint': "/persons/api/datagrid_data_familyattendees/", 'targeting_attendee_id': targeting_attendee_id, - # 'current_organization_slug': current_organization_slug, - # 'current_division_slug': current_division_slug, - # 'current_assembly_id': current_assembly_id, + 'divisions': dumps(list(Division.objects.filter(organization=self.request.user.attendee.division.organization).values("id", "display_name"))), # to avoid simultaneous AJAX calls + 'attendee_search': '/persons/api/datagrid_data_attendees/', 'attendee_urn': f"/persons/attendee/", }) return context @@ -71,7 +67,7 @@ def render_to_response(self, context, **kwargs): # attendee_id "new" only happe context.update({'attendee_endpoint': "/persons/api/datagrid_data_attendee/"}) return render(self.request, self.get_template_names()[0], context) else: - time.sleep(2) + sleep(2) raise Http404('Have you registered any events of the organization?') diff --git a/attendees/scripts/load_access_csv.py b/attendees/scripts/load_access_csv.py index 0e28ccea..5d06adde 100644 --- a/attendees/scripts/load_access_csv.py +++ b/attendees/scripts/load_access_csv.py @@ -1008,7 +1008,7 @@ def update_attendee_photo(attendee, photo_names): def return_two_phones(phones): - cleaned_phones = list(set([re.sub("[^0-9\+()-]+", "", p) for p in phones if (p and not p.isspace())])) + cleaned_phones = list(set([re.sub("[^0-9\+]+", "", p) for p in phones if (p and not p.isspace())])) return (cleaned_phones + [None, None])[0:2] @@ -1026,9 +1026,6 @@ def save_two_phones(attendee, phone): def add_int_code(phone, default='+1'): if phone and not phone.isspace(): - if '-' not in phone and len(phone) == 10: - phone = f'({phone[0:3]}){phone[3:6]}-{phone[6:10]}' - if '+' in phone: return phone else: diff --git a/attendees/static/js/persons/attendee_update_view.js b/attendees/static/js/persons/attendee_update_view.js index e3b99a74..caf02812 100644 --- a/attendees/static/js/persons/attendee_update_view.js +++ b/attendees/static/js/persons/attendee_update_view.js @@ -38,6 +38,7 @@ Attendees.datagridUpdate = { display_name: 'main', // content_type: parseInt(document.querySelector('div.datagrid-attendee-update').dataset.attendeeContenttypeId), }, + duplicatesForNewAttendeeDatagrid: null, familyAttendeeDatagrid: null, familyAttrPopupDxForm: null, familyAttrPopupDxFormData: {}, @@ -49,6 +50,7 @@ Attendees.datagridUpdate = { display_name: '', // will be assigned later }, meetCharacters: null, + divisionIdNames: null, init: () => { console.log('/static/js/persons/attendee_update_view.js'); @@ -89,23 +91,25 @@ Attendees.datagridUpdate = { $('span.attendee-form-submits').dxButton('instance').option('disabled', !enabled); $('button.attending-button-new, button.family-button-new, button.place-button-new, input.form-check-input').prop('disabled', !enabled); Attendees.datagridUpdate.attendeeMainDxForm.option('readOnly', !enabled); + Attendees.datagridUpdate.phone1.option('readOnly', !enabled); + Attendees.datagridUpdate.phone2.option('readOnly', !enabled); Attendees.datagridUpdate.attendeePhotoFileUploader.option('disabled', !enabled); if (enabled) { Attendees.datagridUpdate.familyAttendeeDatagrid.clearGrouping(); - Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.infos.names.original', 'visible', false); - Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.first_name', 'visible', true); - Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.last_name', 'visible', true); - Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.last_name2', 'visible', true); - Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.first_name2', 'visible', true); + // Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.infos.names.original', 'visible', false); + // Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.first_name', 'visible', true); + // Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.last_name', 'visible', true); + // Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.last_name2', 'visible', true); + // Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.first_name2', 'visible', true); Attendees.datagridUpdate.relationshipDatagrid && Attendees.datagridUpdate.relationshipDatagrid.clearGrouping(); } else { - Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.first_name', 'visible', false); - Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.last_name', 'visible', false); - Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.last_name2', 'visible', false); - Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.first_name2', 'visible', false); - Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.infos.names.original', 'visible', true); + // Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.first_name', 'visible', false); + // Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.last_name', 'visible', false); + // Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.last_name2', 'visible', false); + // Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.first_name2', 'visible', false); + // Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('attendee.infos.names.original', 'visible', true); Attendees.datagridUpdate.familyAttendeeDatagrid.columnOption('family.id', 'groupIndex', 0); Attendees.datagridUpdate.relationshipDatagrid && Attendees.datagridUpdate.relationshipDatagrid.columnOption("in_family", "groupIndex", 0); @@ -150,7 +154,9 @@ Attendees.datagridUpdate = { initAttendeeForm: () => { Attendees.datagridUpdate.attendeeAttrs = document.querySelector('div.datagrid-attendee-update'); - Attendees.datagridUpdate.attendeeUrn = Attendees.datagridUpdate.attendeeAttrs.attendeeUrn; + Attendees.datagridUpdate.divisions = JSON.parse(Attendees.datagridUpdate.attendeeAttrs.dataset.divisions); + Attendees.datagridUpdate.divisionIdNames = Attendees.datagridUpdate.divisions.reduce((obj, item) => ({...obj, [item.id]: item.display_name}) ,{}); + Attendees.datagridUpdate.attendeeUrn = Attendees.datagridUpdate.attendeeAttrs.dataset.attendeeUrn; Attendees.datagridUpdate.attendeeId = document.querySelector('input[name="attendee-id"]').value; // Attendees.datagridUpdate.placeDefaults.object_id = Attendees.datagridUpdate.attendeeId; $.ajaxSetup({ @@ -211,6 +217,7 @@ Attendees.datagridUpdate = { }, getAttendeeFormConfigs: () => { // this is the place to control blocks of AttendeeForm + const isCreatingNewAttendee = Attendees.datagridUpdate.attendeeId === 'new'; const basicItems = [ { colSpan: 4, @@ -294,6 +301,29 @@ Attendees.datagridUpdate = { }, ]; + const potentialDuplicatesForNewAttendee = [ + { + colSpan: 24, + colCount: 24, + caption: "Maybe it already exists, so there's no need to create new?", + cssClass: 'h6', + itemType: 'group', + items: [ + { + colSpan: 24, + dataField: 'duplicates_new_attendee', + name: 'duplicatesForNewAttendeeDatagrid', + label: { + location: 'top', + text: ' ', // empty space required for removing label + showColon: false, + }, + template: (data, itemElement) => Attendees.datagridUpdate.initDuplicatesForNewAttendeeDatagrid(data, itemElement), + } + ], + }, + ]; + const moreItems = [ { colSpan: 24, @@ -391,7 +421,7 @@ Attendees.datagridUpdate = { items: [ { colSpan: 24, - dataField: 'familyattendee_set', + dataField: 'families', name: 'familyAttrs', label: { text: 'families', @@ -400,7 +430,7 @@ Attendees.datagridUpdate = { Attendees.datagridUpdate.familyButtonFactory({ text: 'New family for attendee+', disabled: !Attendees.utilities.editingEnabled, - title: '+ Add the attendee to a new family', + title: '+ Create a new family for the attendee', type: 'button', class: 'family-button-new family-button btn-outline-primary btn button btn-sm ', }).appendTo(itemElement); @@ -583,105 +613,112 @@ Attendees.datagridUpdate = { }, ]; + const saveButtons = [ + { + itemType: 'button', + name: 'mainAttendeeFormSubmit', + horizontalAlignment: 'left', + buttonOptions: { + elementAttr: { + class: 'attendee-form-submits', // for toggling editing mode + }, + disabled: !Attendees.utilities.editingEnabled, + text: 'Save Attendee details and photo', + icon: 'save', + hint: 'save attendee data in the page', + type: 'default', + useSubmitBehavior: false, + onClick: (e) => Attendees.datagridUpdate.submitAttendeeForm(e, 'Are you sure?', {}), + }, + }, + ]; + + const deadAndDeleteButtons = [ + { + itemType: 'button', + name: 'mainAttendeeFormDead', + horizontalAlignment: 'left', + buttonOptions: { + elementAttr: { + class: 'attendee-form-dead', // for toggling editing mode + }, + disabled: !Attendees.utilities.editingEnabled, + text: 'Pass away', + icon: 'fas fa-dizzy', + hint: "Attendee sadly passed away, let's ending all activities", + type: 'danger', + stylingMode: 'outlined', + useSubmitBehavior: false, + onClick: (e) => Attendees.datagridUpdate.submitAttendeeForm(e, 'Did attendee die? All activities of the attendee will be ended (not deleted).', {'X-End-All-Attendee-Activities': true}), + }, + }, + { + itemType: 'button', + name: 'mainAttendeeFormDelete', + horizontalAlignment: 'left', + buttonOptions: { + elementAttr: { + class: 'attendee-form-delete', // for toggling editing mode + }, + disabled: !Attendees.utilities.editingEnabled, + text: "Delete attendee", + icon: 'trash', + hint: "delete attendee's all data in the page", + type: 'danger', + onClick: (e) => { + if (confirm("Sure to delete ALL data of the attendee? Everything of the attendee will be removed. Instead, setting finish/deathday is usually enough!")) { + window.scrollTo(0, 0); + $('div.spinner-border').show(); + $.ajax({ + url: Attendees.datagridUpdate.attendeeAjaxUrl, + method: 'DELETE', + success: (response) => { + $('div.spinner-border').hide(); + DevExpress.ui.notify( + { + message: 'delete attendee success', + width: 500, + position: { + my: 'center', + at: 'center', + of: window, + }, + }, 'info', 2500); + window.location = new URL(window.location.origin); + }, + error: (response) => { + console.log('Failed to delete data for main AttendeeForm, error: ', response); + DevExpress.ui.notify( + { + message: 'saving attendee error', + width: 500, + position: { + my: 'center', + at: 'center', + of: window, + }, + }, 'error', 5000); + }, + }); + } + } + }, + }, + ]; + const buttonItems = [ { colSpan: 24, colCount: 24, cssClass: 'h6 not-shrinkable', itemType: 'group', - items: [ - { - itemType: 'button', - name: 'mainAttendeeFormSubmit', - horizontalAlignment: 'left', - buttonOptions: { - elementAttr: { - class: 'attendee-form-submits', // for toggling editing mode - }, - disabled: !Attendees.utilities.editingEnabled, - text: 'Save Attendee details and photo', - icon: 'save', - hint: 'save attendee data in the page', - type: 'default', - useSubmitBehavior: false, - onClick: (e) => Attendees.datagridUpdate.submitAttendeeForm(e, 'Are you sure?', {}), - }, - }, - { - itemType: 'button', - name: 'mainAttendeeFormDead', - horizontalAlignment: 'left', - buttonOptions: { - elementAttr: { - class: 'attendee-form-dead', // for toggling editing mode - }, - disabled: !Attendees.utilities.editingEnabled, - text: 'Passed away', - icon: 'fas fa-dizzy', - hint: 'Attendee passed away, sadly ending all activities', - type: 'danger', - stylingMode: 'outlined', - useSubmitBehavior: false, - onClick: (e) => Attendees.datagridUpdate.submitAttendeeForm(e, 'Did attendee die? All activities of the attendee will be ended (not deleted).', {'X-End-All-Attendee-Activities': true}), - }, - }, - { - itemType: 'button', - name: 'mainAttendeeFormDelete', - horizontalAlignment: 'left', - buttonOptions: { - elementAttr: { - class: 'attendee-form-delete', // for toggling editing mode - }, - disabled: !Attendees.utilities.editingEnabled, - text: "CCPA Delete", - icon: 'trash', - hint: "delete attendee's all data in the page", - type: 'danger', - onClick: (e) => { - if (confirm("Did you receive people's formal request to delete all data of the attendee? Everything of the attendee will be removed. Instead, setting finish/deathday is usually enough!")) { - window.scrollTo(0, 0); - $('div.spinner-border').show(); - $.ajax({ - url: Attendees.datagridUpdate.attendeeAjaxUrl, - method: 'DELETE', - success: (response) => { - $('div.spinner-border').hide(); - DevExpress.ui.notify( - { - message: 'delete attendee success', - width: 500, - position: { - my: 'center', - at: 'center', - of: window, - }, - }, 'info', 2500); - window.location = new URL(window.location.origin); - }, - error: (response) => { - console.log('Failed to delete data for main AttendeeForm, error: ', response); - DevExpress.ui.notify( - { - message: 'saving attendee error', - width: 500, - position: { - my: 'center', - at: 'center', - of: window, - }, - }, 'error', 5000); - }, - }); - } - } - }, - }, - ], + items: isCreatingNewAttendee ? [...saveButtons] : [...saveButtons, ...deadAndDeleteButtons], }, ]; - const originalItems = [...basicItems, ...buttonItems, ...(Attendees.datagridUpdate.attendeeId === 'new' ? [] : moreItems )]; + const originalItems = isCreatingNewAttendee ? + [...basicItems, ...potentialDuplicatesForNewAttendee, ...buttonItems] : + [...basicItems, ...buttonItems, ...moreItems]; return { showValidationSummary: true, @@ -692,6 +729,9 @@ Attendees.datagridUpdate = { }, onFieldDataChanged: (e) => { Attendees.datagridUpdate.attendeeMainDxForm.validate(); + if (isCreatingNewAttendee) { + Attendees.datagridUpdate.duplicatesForNewAttendeeDatagrid.refresh(); + } }, colCount: 24, formData: null, // will be fetched @@ -872,11 +912,13 @@ Attendees.datagridUpdate = { key: 'id', loadMode: 'raw', load: () => { - const d = $.Deferred(); - $.get(Attendees.datagridUpdate.attendeeAttrs.dataset.divisionsEndpoint).done((response) => { - d.resolve(response.data); - }); - return d.promise(); + // const d = $.Deferred(); + // $.get(Attendees.datagridUpdate.attendeeAttrs.dataset.divisionsEndpoint) + // .done( response => { + // d.resolve(response.data); + // }); + // return d.promise(); + return Attendees.datagridUpdate.divisions; } }) }), @@ -981,6 +1023,16 @@ Attendees.datagridUpdate = { editorOptions: { placeholder: '+1(000)000-0000', }, + template: (data, itemElement) => { + const options = { + readOnly: !Attendees.utilities.editingEnabled, + value: Attendees.utilities.phoneNumberFormatter(data.editorOptions.value), + onValueChanged: (e) => data.component.updateData(data.dataField, e.value.replace(/[-()]/g, '')), + }; + const phoneEditor = $("
").dxTextBox(options); + itemElement.append(phoneEditor); + Attendees.datagridUpdate.phone1 = phoneEditor.dxTextBox('instance'); + }, validationRules: [ { type: 'pattern', @@ -999,6 +1051,16 @@ Attendees.datagridUpdate = { editorOptions: { placeholder: '+1(000)000-0000', }, + template: (data, itemElement) => { + const options = { + readOnly: !Attendees.utilities.editingEnabled, + value: Attendees.utilities.phoneNumberFormatter(data.editorOptions.value), + onValueChanged: (e) => data.component.updateData(data.dataField, e.value.replace(/[-()]/g, '')), + }; + const phoneEditor = $("
").dxTextBox(options); + itemElement.append(phoneEditor); + Attendees.datagridUpdate.phone2 = phoneEditor.dxTextBox('instance'); + }, validationRules: [ { type: 'pattern', @@ -1115,7 +1177,7 @@ Attendees.datagridUpdate = { { dataField: 'contactValue', editorOptions: { - placeholder: 'for example: WeiXin', + placeholder: 'for example: JohnSmith1225', }, helpText: 'Contact such as name@email.com/+15101234567 etc', label: { @@ -2133,6 +2195,107 @@ Attendees.datagridUpdate = { }), + /////////////////////// Potential duplicate Attendees Datagrid under new Attendee DxForm /////////////////////// + + initDuplicatesForNewAttendeeDatagrid: (data, itemElement) => { + const $myDatagrid = $("
").dxDataGrid(Attendees.datagridUpdate.duplicatesForNewAttendeeDatagridConfig); + itemElement.append($myDatagrid); + Attendees.datagridUpdate.duplicatesForNewAttendeeDatagrid = $myDatagrid.dxDataGrid('instance'); + }, + + duplicatesForNewAttendeeDatagridConfig: { + dataSource: { + store: new DevExpress.data.CustomStore({ + key: 'id', + load: () => { + const firstName = Attendees.datagridUpdate.attendeeMainDxForm.getEditor('first_name').option('value'); + const lastName = Attendees.datagridUpdate.attendeeMainDxForm.getEditor('last_name').option('value'); + const firstName2 = Attendees.datagridUpdate.attendeeMainDxForm.getEditor('first_name2').option('value'); + const lastName2 = Attendees.datagridUpdate.attendeeMainDxForm.getEditor('last_name2').option('value'); + const phone1 = Attendees.datagridUpdate.attendeeMainDxForm.getEditor('last_name2').option('value'); + + if (firstName || lastName || firstName2 || lastName2 || phone1) { + const searchData = [firstName, lastName, firstName2, lastName2, phone1].filter(name => name).map(name => ["infos", "contains", name]).flatMap(e => [e, 'or']); + const d = new $.Deferred(); + $.get(Attendees.datagridUpdate.attendeeAttrs.dataset.attendeeSearch, {include_dead: true, take: 10, filter: JSON.stringify(searchData)}) + .done( result => { + d.resolve(result.data); + }); + return d.promise(); + } + }, + }), + }, + allowColumnReordering: true, + columnAutoWidth: true, + allowColumnResizing: true, + rowAlternationEnabled: true, + hoverStateEnabled: true, + columns: [ + { + caption: "Full name", + dataField: "infos.names", + allowSorting: false, + dataType: "string", + allowHeaderFiltering: false, + cellTemplate: (container, rowData) => { + const attrs = { + "class": "text-info", + "text": rowData.data.infos.names.original, + "href": Attendees.datagridUpdate.attendeeUrn + rowData.data.id, + }; + $($('', attrs)).appendTo(container); + }, + }, + { + dataField: 'gender', + allowSorting: false, + }, + { + dataField: 'division', + allowSorting: false, + lookup: { + valueExpr: 'id', + displayExpr: 'display_name', + dataSource: { + store: new DevExpress.data.CustomStore({ + key: 'id', + load: () => { + return $.getJSON(Attendees.datagridUpdate.attendeeAttrs.dataset.divisionsEndpoint); + }, + byKey: (key) => { + const d = new $.Deferred(); + $.get(Attendees.datagridUpdate.attendeeAttrs.dataset.divisionsEndpoint, {division_id: key}) + .done( result => { + d.resolve(result.data); + }); + return d.promise(); + }, + }), + }, + } + }, + { + caption: "Birthday Y-M-D", + dataField: "birthday", + dataType: "string", + allowSorting: false, + allowHeaderFiltering: false, + cellTemplate: (container, rowData) => { + if (rowData.data.actual_birthday || rowData.data.estimated_birthday) { + const attrs = { + "text": rowData.data.actual_birthday ? rowData.data.actual_birthday : `around ${rowData.data.estimated_birthday}`, + }; + $($('', attrs)).appendTo(container); + } + }, + }, + { + dataField: "deathday", + }, + ], + }, + /////////////////////// Family Attendees Datagrid in main DxForm /////////////////////// @@ -2179,6 +2342,7 @@ Attendees.datagridUpdate = { }); }, insert: function (values) { +console.log("hi 2340 here is inserting values: ", values); return $.ajax({ url: Attendees.datagridUpdate.attendeeAttrs.dataset.familyAttendeesEndpoint, method: 'POST', @@ -2306,107 +2470,52 @@ Attendees.datagridUpdate = { }, }, { - dataField: 'attendee.gender', + dataField: 'attendee', validationRules: [{type: 'required'}], - caption: 'Gender', - lookup: { - valueExpr: 'name', - displayExpr: 'name', - dataSource: Attendees.utilities.genderEnums(), - } - }, - { - caption: 'Full name', - dataField: 'attendee.infos.names.original', - allowEditing: false, + caption: 'Attendee', cellTemplate: (container, rowData) => { - if (rowData.data.attendee.id === Attendees.datagridUpdate.attendeeId) { - $('', {text: rowData.data.attendee.infos.names.original}).appendTo(container); + if (rowData.value === Attendees.datagridUpdate.attendeeId) { + $('', {text: rowData.displayValue}).appendTo(container); } else { const attrs = { class: 'text-info', - text: rowData.data.attendee.infos.names.original, - href: Attendees.datagridUpdate.attendeeAttrs.dataset.attendeeUrn + rowData.data.attendee.id, + text: rowData.displayValue, + href: Attendees.datagridUpdate.attendeeAttrs.dataset.attendeeUrn + rowData.value, }; $('', attrs).appendTo(container); } }, - }, - { - caption: 'First name', - dataField: 'attendee.first_name', - visible: false, - validationRules: [ - { - type: 'stringLength', - max: 25, - message: 'first name cannot exceed 25 characters' - }, - ], - }, - { - caption: 'Last name', - dataField: 'attendee.last_name', - visible: false, - validationRules: [ - { - type: 'stringLength', - max: 25, - message: 'last name cannot exceed 25 characters' - }, - ], - }, - { - caption: 'Last name2', - dataField: 'attendee.last_name2', - visible: false, - validationRules: [ - { - type: 'stringLength', - max: 8, - message: 'last name 2 cannot exceed 8 characters' - }, - ], - }, - { - caption: 'First name2', - dataField: 'attendee.first_name2', - visible: false, - validationRules: [ - { - type: 'stringLength', - max: 12, - message: 'last name 2 cannot exceed 12 characters' - }, - ], - }, - { - dataField: 'attendee.division', - validationRules: [{type: 'required'}], - caption: 'Attendee Division', lookup: { valueExpr: 'id', - displayExpr: 'display_name', + displayExpr: (item) => { + const division_name = Attendees.datagridUpdate.divisionIdNames[item.division] ? ` [${Attendees.datagridUpdate.divisionIdNames[item.division]}]` : ''; + return item ? `(${item.gender[0]}) ${item.infos.names.original}${division_name}${item.deathday ? ', deathday: ' + item.deathday : ''}` : null; + }, dataSource: { store: new DevExpress.data.CustomStore({ key: 'id', - load: () => { - return $.getJSON(Attendees.datagridUpdate.attendeeAttrs.dataset.divisionsEndpoint); + load: (searchOpts) => { + const params = {}; + if (searchOpts.searchValue) { + const searchCondition = ['infos__names', searchOpts.searchOperation, searchOpts.searchValue]; + params.filter = JSON.stringify(searchCondition); + } + return $.getJSON(Attendees.datagridUpdate.attendeeAttrs.dataset.relatedAttendeesEndpoint, params); }, byKey: (key) => { const d = new $.Deferred(); - $.get(Attendees.datagridUpdate.attendeeAttrs.dataset.divisionsEndpoint, {division_id: key}) - .done((result) => { + $.get(Attendees.datagridUpdate.attendeeAttrs.dataset.relatedAttendeesEndpoint + key + '/') + .done( result => { d.resolve(result.data); }); return d.promise(); }, }), }, - } + }, }, { - dataField: 'deathday', + dataField: 'start', dataType: 'date', editorOptions: { dateSerializationFormat: 'yyyy-MM-dd', @@ -2713,7 +2822,7 @@ Attendees.datagridUpdate = { success: (result) => { DevExpress.ui.notify( { - message: 'update success, please reload page if changing family', + message: 'update success', // please reload page if changing family? width: 500, position: { my: 'center', @@ -2831,8 +2940,13 @@ Attendees.datagridUpdate = { dataSource: { store: new DevExpress.data.CustomStore({ key: 'id', - load: () => { - return $.getJSON(Attendees.datagridUpdate.attendeeAttrs.dataset.relationsEndpoint, {take: 100}); + load: (searchOpts) => { + const params = {take: 100}; + if (searchOpts.searchValue) { + const searchCondition = ['title', searchOpts.searchOperation, searchOpts.searchValue]; + params.filter = JSON.stringify(searchCondition); + } + return $.getJSON(Attendees.datagridUpdate.attendeeAttrs.dataset.relationsEndpoint, params); }, byKey: (key) => { const d = new $.Deferred(); @@ -2914,7 +3028,7 @@ Attendees.datagridUpdate = { caption: 'Contact when Main attendee in emergency', dataType: "boolean", calculateCellValue: (rowData) => { - return rowData.scheduler ? rowData.scheduler : false; + return rowData.emergency_contact ? rowData.emergency_contact : false; }, }, { diff --git a/attendees/static/js/persons/attendees_list_view.js b/attendees/static/js/persons/attendees_list_view.js index 6992cf43..dd007329 100644 --- a/attendees/static/js/persons/attendees_list_view.js +++ b/attendees/static/js/persons/attendees_list_view.js @@ -129,13 +129,19 @@ Attendees.dataAttendees = { pageSize:10 }, pager: { + visible: true, showPageSizeSelector: true, allowedPageSizes: [10, 30, 5000] }, stateStoring: { enabled: true, - type: "sessionStorage", storageKey: "attendeesAttendeesList", + type: "custom", // "sessionStorage", + customLoad: () => JSON.parse(sessionStorage.getItem("attendeesAttendeesList")), + customSave: (state) => { + if (state && state.searchText) {state.searchText = "";} // don't store user search terms + sessionStorage.setItem("attendeesAttendeesList", JSON.stringify(state)); + }, }, columns: null, // will be initialized later. }, @@ -221,7 +227,7 @@ Attendees.dataAttendees = { const phoneNumber = rowData.data.infos.contacts[key].trim(); const attrs = { "class": "text-info", - "text": phoneNumber, + "text": Attendees.utilities.phoneNumberFormatter(phoneNumber), "href": `tel:${phoneNumber}`, }; if (phones > 0) {$('', {text: ', '}).appendTo(container);} diff --git a/attendees/static/js/shared/utilities.js b/attendees/static/js/shared/utilities.js index 1f894afc..a188b98b 100644 --- a/attendees/static/js/shared/utilities.js +++ b/attendees/static/js/shared/utilities.js @@ -153,6 +153,34 @@ Attendees.utilities = { email1: null, email2: null, }, + + phoneNumberFormatter: (rawNumberText) => { + if (rawNumberText) { + switch (true) { + case rawNumberText.startsWith('+1') && rawNumberText.length === 12: // US + return `${rawNumberText.slice(0, 2)}(${rawNumberText.slice(2, 5)})${rawNumberText.slice(5, 8)}-${rawNumberText.slice(8)}`; + case rawNumberText.startsWith('+86') && [11, 12, 13].includes(rawNumberText.length): // China + if (rawNumberText.length < 13) { + return `${rawNumberText.slice(0, 3)}(${rawNumberText.slice(3, 5)})${rawNumberText.slice(5, 9)}-${rawNumberText.slice(9)}`; + } else { + return `${rawNumberText.slice(0, 3)}(${rawNumberText.slice(3, 6)})${rawNumberText.slice(6, 10)}-${rawNumberText.slice(10)}`; + } + case rawNumberText.startsWith('+886') && [12, 13].includes(rawNumberText.length): // Taiwan + if (rawNumberText.length > 12 && rawNumberText[4] === '9') { + return `${rawNumberText.slice(0, 4)}(${rawNumberText.slice(4, 7)})${rawNumberText.slice(7, 10)}-${rawNumberText.slice(10)}`; + } else if (rawNumberText.length > 12) { + return `${rawNumberText.slice(0, 4)}(${rawNumberText.slice(4, 5)})${rawNumberText.slice(5, 9)}-${rawNumberText.slice(9)}`; + } else { + return `${rawNumberText.slice(0, 4)}(${rawNumberText.slice(4, 5)})${rawNumberText.slice(5, 8)}-${rawNumberText.slice(8)}`; + } + case rawNumberText.startsWith('+852') && rawNumberText.length === 12: // HK + return `${rawNumberText.slice(0, 1)}(${rawNumberText.slice(1, 4)})${rawNumberText.slice(4, 8)}-${rawNumberText.slice(8)}`; + default: + return rawNumberText; + } + } + return ''; + } }; $(document).ready(() => { diff --git a/attendees/templates/persons/attendee_update_view.html b/attendees/templates/persons/attendee_update_view.html index 93ff07b0..bf262c92 100644 --- a/attendees/templates/persons/attendee_update_view.html +++ b/attendees/templates/persons/attendee_update_view.html @@ -49,13 +49,26 @@

- {% if show_create_nonfamily_attendee %} - - +Add Attendee - + {% if show_create_attendee %} + {% endif %} {% if targeting_attendee_id != 'new' %}
data-assemblies-endpoint="{{assemblies_endpoint}}" data-attendee-endpoint="{{attendee_endpoint}}" data-family-attendees-endpoint="{{family_attendees_endpoint}}" + data-divisions="{{divisions}}" + data-attendee-search="{{attendee_search}}" data-attendee-urn="{{attendee_urn}}" >
diff --git a/attendees/users/models/user.py b/attendees/users/models/user.py index d2093a5f..423e83c2 100644 --- a/attendees/users/models/user.py +++ b/attendees/users/models/user.py @@ -99,4 +99,4 @@ def allowed_url_names(self, menu_category='API'): return self.groups.filter( menuauthgroup__menu__organization=self.organization, menuauthgroup__menu__category=menu_category, - ).values_list('menuauthgroup__menu__url_name', flat=True) + ).values_list('menuauthgroup__menu__url_name', flat=True).distinct() diff --git a/fixtures/db_seed.json b/fixtures/db_seed.json index ff5d1b82..1d9b0a63 100644 --- a/fixtures/db_seed.json +++ b/fixtures/db_seed.json @@ -3549,6 +3549,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3570,6 +3572,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3591,6 +3595,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3612,6 +3618,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3633,6 +3641,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3654,6 +3664,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3675,6 +3687,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3696,6 +3710,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3717,6 +3733,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3738,6 +3756,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3759,6 +3779,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3780,6 +3802,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3801,6 +3825,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3822,6 +3848,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3843,6 +3871,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3864,6 +3894,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3885,6 +3917,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3906,6 +3940,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3927,6 +3963,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3948,6 +3986,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3969,6 +4009,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -3990,6 +4032,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -4011,6 +4055,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -4032,6 +4078,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -4053,6 +4101,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -4074,6 +4124,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -4095,6 +4147,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -4116,6 +4170,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -4137,6 +4193,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -4158,6 +4216,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -4179,6 +4239,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -4200,6 +4262,8 @@ "finish": null, "infos": { "comment": null, + "updated_by": {}, + "body": null, "show_secret": {} } } @@ -9992,8 +10056,8 @@ "contacts": { "email1": "5greata@email.com", "email2": "5greatb@email.com", - "phone1": "+1(555)555-5555", - "phone2": "+44(191)203-7010" + "phone1": "+15555555555", + "phone2": "+441912037010" } } } @@ -10031,8 +10095,9 @@ "contacts": { "email1": "6coola@email.com", "email2": "6coolb@email.com", - "phone1": "+1(666)666-6666", - "phone2": "+86(66)66-66666" + "phone1": "+1886222222222", + "phone2": "+86666666666", + "phone3": "+188666666666" } } } @@ -10164,8 +10229,8 @@ "contacts": { "email1": "7coola@email.com", "email2": "7coolb@email.com", - "phone1": "+1(777)777-7777", - "phone2": "+27(77)777-7777" + "phone1": "+60123456789", + "phone2": "+85212345678" } } } @@ -10232,8 +10297,8 @@ "contacts": { "email1": "8coola@email.com", "email2": "8coolb@email.com", - "phone1": "+1(888)888-8888", - "phone2": "+2(888)888-8888" + "phone1": "+18888888888", + "phone2": "+28888888888" } } }