diff --git a/README.md b/README.md index dab2d957..5d38ff20 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ export DJANGO_SECRET_KEY=<> * if using proxy on apache, please set `ProxyPreserveHost on` to pass host header in requests, so that the email links will have the correct domain name. * For keeping the site surviving reboot, add starting of the Django upon system reboot, such as `sudo su user_name sh -c 'sleep 99 && cd ~user_name/repo_dir && docker-compose -f production.yml up -d' >> /tmp/attendee_startup.log` (need work) +* enter Redis-CLI by `docker exec -it redis redis-cli` ## DB SQL Backup & Restore process (with production.yml)
@@ -293,7 +294,7 @@ DJANGO_SECRET_KEY=your_django_secret_key docker-compose -f local.yml run django python manage.py dumpdata -e users.user -e admin.logentry -e sessions.session -e contenttypes.contenttype -e sites.site -e account.emailaddress -e account.emailconfirmation -e socialaccount.socialtoken -e auth.permission -e pghistory.context -e pghistory.aggregateevent -e users.userhistory -e users.menushistory -e users.menuauthgroupshistory -e users.groupshistory -e users.grouppermissionshistory -e users.usergroupshistory -e users.userpermissionshistory -e users.emailaddresshistory -e users.emailconfirmationhistory -e whereabouts.organizationshistory -e whereabouts.divisionshistory -e whereabouts.placeshistory -e whereabouts.campuseshistory -e whereabouts.propertieshistory -e whereabouts.suiteshistory -e whereabouts.roomshistory -e whereabouts.countryhistory -e whereabouts.statehistory -e whereabouts.localityhistory -e whereabouts.addresshistory -e persons.categorieshistory -e persons.noteshistory -e persons.pastshistory -e persons.folkshistory -e persons.attendeeshistory -e persons.folkattendeeshistory -e persons.relationshistory -e persons.registrationshistory -e persons.attendingshistory -e persons.attendingmeetshistory -e occasions.assemblieshistory -e occasions.attendanceshistory -e occasions.charactershistory -e occasions.gatheringshistory -e occasions.meetshistory -e occasions.messagetemplateshistory -e occasions.priceshistory -e occasions.teamshistory -e occasions.calendarhistory -e occasions.calendarrelationhistory -e occasions.eventhistory -e occasions.eventrelationhistory -e occasions.occurrencehistory -e occasions.rulehistory -e occasions.periodictaskhistory -e occasions.crontabschedulehistory -e occasions.intervalschedulehistory -e users.permissionshistory -e users.GroupPermissionProxy -e users.UserGroupProxy -e users.UserPermissionProxy --indent 2 > fixtures/db_seed2.json ``` * go to Django admin to add the first organization and all groups to the first user (superuser) at http://<>:8008/admin/users/user/ -* to see django log: `docker-compose -f production.yml logs django` +* to see django log: `docker-compose -f local.yml logs django`
## [How to start dev env on Windows](https://cookiecutter-django.readthedocs.io/en/latest/developing-locally-docker.html) @@ -326,6 +327,7 @@ Please add your IP to ALLOWED_HOSTS in config/settings/local.py * Enter Django console by `docker-compose -f local.yml run django python manage.py shell_plus` * remote debug in PyCharm for docker, please check [django cookie doc](https://github.com/pydanny/cookiecutter-django/blob/master/{{cookiecutter.project_slug}}/docs/pycharm/configuration.rst). +* enter Redis-CLI by `docker exec -it redis redis-cli` ## How to start dev env on macOS with VirtualBox and docker-machine
@@ -370,16 +372,23 @@ All libraries are included to facilitate offline development, it will take port * check local python version, Django coockie cutter is developed with Python 3 * Install pre-commit for python, such as `pip3 install pre-commit` (pre-commit settings are at .git/hooks/pre-commit). * There is no need to have local docker machine, Django or Postgres running. -* Install and start [docker desktop](https://www.docker.com/products/docker-desktop) (including docker compose), and [add local repo directory to file sharing in docker desktop preference](https://docs.docker.com/desktop/mac/#file-sharing). +* Add .envs/.local/.sendgrid.env +```commandline +SENDGRID_API_KEY=<> +DJANGO_DEFAULT_FROM_EMAIL=<> +EMAIL_HOST=sendgrid +``` +* Install and start [docker desktop](https://www.docker.com/products/docker-desktop) (including docker compose), and [add local repo directory to file sharing in docker desktop preference](https://docs.docker.com/desktop/settings/mac/#file-sharing). * build and start the CentOS based local machine by `docker-compose -f local.yml build && docker-compose -f local.yml up -d`, your site will be at http://0.0.0.0:8008/ * to see django log: `docker-compose -f local.yml logs django` +* enter Redis-CLI by `docker exec -it redis redis-cli` ## DB SQL Backup & Restore process (with local.yml) * backup current db to container `docker-compose -f local.yml exec postgres backup` * list backup files in container `docker-compose -f local.yml exec postgres backups` * copy all backup files from container to dev local computer `docker cp $(docker-compose -f local.yml ps -q postgres):/backups ./backups` -* copy all backup files from dev local computer to container `docker cp ./backups/* $(docker-compose -f local.yml ps -q postgres):/backups/` +* copy a backup file from dev local computer to container `docker cp ./backups/ $(docker-compose -f local.yml ps -q postgres):/backups/` * restore a backup from a backup file in container `docker-compose -f local.yml exec postgres restore backup_2018_03_13T09_05_07.sql.gz` * print INSERT commands for a table `docker-compose -f local.yml exec postgres pg_dump --column-inserts --data-only --table=<> -d attendees --username=<>` diff --git a/attendees/context_processors.py b/attendees/context_processors.py index f7f7b39e..10ae2501 100644 --- a/attendees/context_processors.py +++ b/attendees/context_processors.py @@ -3,7 +3,6 @@ from datetime import datetime from django.conf import settings from urllib import parse -import json from attendees.users.models import Menu @@ -32,7 +31,7 @@ def common_variables(request): # TODO move organization info to view 'timezone_name': datetime.now(timezone(parse.unquote(tzname))).tzname(), 'user_organization_name': user_organization_name, 'user_organization_name_slug': user_organization_name_slug, - 'user_api_allowed_url_name': json.dumps({name: True for name in request.user.allowed_url_names()} if hasattr(request.user, 'allowed_url_names') else {}), + 'user_api_allowed_url_name': {name: True for name in request.user.allowed_url_names()} if hasattr(request.user, 'allowed_url_names') else {}, 'user_attendee_id': user_attendee_id, 'main_menus': main_menus, } diff --git a/attendees/occasions/admin.py b/attendees/occasions/admin.py index 20e94677..406027f5 100644 --- a/attendees/occasions/admin.py +++ b/attendees/occasions/admin.py @@ -114,7 +114,7 @@ class AssemblyAdmin(PgHistoryPage, admin.ModelAdmin): prepopulated_fields = {"slug": ("display_name",)} # inlines = (AssemblyContactInline,) list_display_links = ("display_name",) - list_display = ("id", "division", "display_name", "slug", "get_addresses") + list_display = ("id", "division", "display_name", "display_order", "slug", "get_addresses") readonly_fields = ["id", "created", "modified"] def formfield_for_foreignkey(self, db_field, request, **kwargs): diff --git a/attendees/occasions/serializers/attendance_etc_serializer.py b/attendees/occasions/serializers/attendance_etc_serializer.py index ba58457b..b542293e 100644 --- a/attendees/occasions/serializers/attendance_etc_serializer.py +++ b/attendees/occasions/serializers/attendance_etc_serializer.py @@ -12,6 +12,8 @@ class AttendanceEtcSerializer(serializers.ModelSerializer): registrant_attendee_id = serializers.CharField(read_only=True) attending__attendee__infos__names__original = serializers.CharField(read_only=True, source='attending_name') photo = serializers.CharField(read_only=True) + attending__attendee__infos__fixed__grade = serializers.CharField(read_only=True) + attending__attendee__infos__fixed__food_pref = serializers.CharField(read_only=True) # file = serializers.FileField(use_url=True, allow_empty_file=True, allow_null=True) # cause 400 or with local domain name file_path = serializers.SerializerMethodField(required=False, read_only=True) encoded_file = serializers.CharField(required=False) diff --git a/attendees/occasions/services/attendance_service.py b/attendees/occasions/services/attendance_service.py index e40d2a2f..b7c7ef29 100644 --- a/attendees/occasions/services/attendance_service.py +++ b/attendees/occasions/services/attendance_service.py @@ -225,6 +225,8 @@ def by_organization_meet_characters(current_user, meet_slugs, character_slugs, s ), output_field=CharField() ) + annotations['attending__attendee__infos__fixed__grade'] = F("attending__attendee__infos__fixed__grade") + annotations['attending__attendee__infos__fixed__food_pref'] = F("attending__attendee__infos__fixed__food_pref") else: if not attendee: annotations['attending__attendee__first_name'] = F("attending__attendee__first_name") diff --git a/attendees/occasions/views/api/organization_meets.py b/attendees/occasions/views/api/organization_meets.py index c2f60871..cc50718e 100644 --- a/attendees/occasions/views/api/organization_meets.py +++ b/attendees/occasions/views/api/organization_meets.py @@ -16,6 +16,7 @@ class OrganizationMeetsViewSet(viewsets.ModelViewSet): """ API endpoint that allows all/grouped Meet in current user's organization filtered by date to be viewed or edited. + ps. Generally speaking meets canNOT be filtered by start/finish unless specified sinces user may want to see future/past ones. Todo 20210711 only coworkers/organizers can see all Meets, general users should only see what they attended Todo 20210815 if limiting by meet's shown_audience, non-coworker assigned to non-public meets won't show """ @@ -63,17 +64,17 @@ def get_queryset(self): extra_filter.add(Q(**terms), Q.AND) if start: - extra_filter.add((Q(finish__isnull=True) | Q(finish__gte=start)), Q.AND) + extra_filter.add(Q(finish__gte=start), Q.AND) if finish: - extra_filter.add((Q(start__isnull=True) | Q(start__lte=finish)), Q.AND) + extra_filter.add(Q(start__lte=finish), Q.AND) return ( - Meet.objects.filter(extra_filter) + Meet.objects.select_related('assembly').filter(extra_filter) .annotate( assembly_name=F("assembly__display_name"), ) - .order_by('assembly_name', 'display_name') + .order_by('assembly__display_order', 'assembly_name', 'display_name') ) else: diff --git a/attendees/occasions/views/api/user_assembly_meets.py b/attendees/occasions/views/api/user_assembly_meets.py index 51e89f73..2d9d91ef 100644 --- a/attendees/occasions/views/api/user_assembly_meets.py +++ b/attendees/occasions/views/api/user_assembly_meets.py @@ -24,6 +24,7 @@ def get_queryset(self): """ Todo: this endpoint is used by datagrid_attendee_update_view page (with params). Do check if the editor and the editing target relations and permissions + ps. Generally speaking meets canNOT be filtered by start/finish since users may want to see future/past ones. :return: """ current_user = self.request.user diff --git a/attendees/occasions/views/page/roster_list_view.py b/attendees/occasions/views/page/roster_list_view.py index a645cc3b..9396d3b3 100644 --- a/attendees/occasions/views/page/roster_list_view.py +++ b/attendees/occasions/views/page/roster_list_view.py @@ -19,7 +19,7 @@ def get_context_data(self, **kwargs): "user_can_write": MenuService.is_user_allowed_to_write(self.request), # "content_type_models_endpoint": "/whereabouts/api/content_type_models/", "series_gatherings_endpoint": "/occasions/api/series_gatherings/", - # "meets_endpoint_by_id": "/occasions/api/user_assembly_meets/", + "grade_converter": self.request.user.organization.infos.get('grade_converter', []) if self.request.user.organization else [], "assemblies_endpoint": "/occasions/api/user_assemblies/", "categories_endpoint": "/persons/api/all_categories/", "gatherings_endpoint": "/occasions/api/organization_team_gatherings/", diff --git a/attendees/persons/admin.py b/attendees/persons/admin.py index 42fb764d..f650ab87 100644 --- a/attendees/persons/admin.py +++ b/attendees/persons/admin.py @@ -228,6 +228,7 @@ class AttendingAdmin(PgHistoryPage, admin.ModelAdmin): models.JSONField: {"widget": JSONEditorWidget}, } search_fields = ( + "id", "attendee__first_name", "attendee__last_name", "attendee__first_name2", @@ -325,6 +326,7 @@ class AttendingMeetAdmin(PgHistoryPage, admin.ModelAdmin): } list_display_links = ("attending",) autocomplete_fields = ('attending',) + search_fields = ('infos', 'attending__attendee__infos',) readonly_fields = ["id", "created", "modified"] list_display = ( "id", diff --git a/attendees/persons/migrations/0013_folk_attendee_m2m.py b/attendees/persons/migrations/0013_folk_attendee_m2m.py index 00645451..8e1f861e 100644 --- a/attendees/persons/migrations/0013_folk_attendee_m2m.py +++ b/attendees/persons/migrations/0013_folk_attendee_m2m.py @@ -22,7 +22,7 @@ class Migration(migrations.Migration): ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), ('is_removed', models.BooleanField(default=False)), - ('display_order', models.SmallIntegerField(db_index=True, default=3000, help_text="0 will be first family")), + ('display_order', models.SmallIntegerField(db_index=True, default=1, help_text="0 will be first family")), ('attendee', models.ForeignKey(on_delete=models.CASCADE, to='persons.Attendee')), ('folk', models.ForeignKey(on_delete=models.CASCADE, to='persons.Folk')), ('role', models.ForeignKey(help_text='[Title] the family role of the attendee?', on_delete=models.SET(0), related_name='role', to='persons.Relation', verbose_name='attendee is')), @@ -67,7 +67,7 @@ class Migration(migrations.Migration): ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), ('is_removed', models.BooleanField(default=False)), - ('display_order', models.SmallIntegerField(default=3000, help_text='0 will be first family')), + ('display_order', models.SmallIntegerField(default=1, help_text='0 will be first family')), ('folk', models.ForeignKey(db_constraint=False, on_delete=models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='persons.folk')), ('attendee', models.ForeignKey(db_constraint=False, on_delete=models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='persons.attendee')), ('role', models.ForeignKey(db_constraint=False, help_text='[Title] the family role of the attendee?', on_delete=models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='persons.relation', verbose_name='attendee is')), diff --git a/attendees/persons/models/attendee.py b/attendees/persons/models/attendee.py index 69aa77bc..4892603d 100644 --- a/attendees/persons/models/attendee.py +++ b/attendees/persons/models/attendee.py @@ -26,6 +26,8 @@ class Attendee(Utility, TimeStampedModel, SoftDeletableModel): FAMILY_CATEGORY = 0 NON_FAMILY_CATEGORY = 25 + PAUSED_CATEGORY = 27 + SCHEDULED_CATEGORY = 1 HIDDEN_ROLE = 0 # RELATIVES_KEYWORDS = ['parent', 'mother', 'guardian', 'father', 'caregiver'] # to find attendee's parents/caregiver in cowokers view of all activities @@ -212,6 +214,13 @@ def under_same_org_with(self, other_attendee_id): ).exists() return False + def can_be_scheduled_by(self, other_attendee_id): + if str(self.id) == other_attendee_id: + return True + return Attendee.objects.filter( + pk=self.id, infos__schedulers__contains={other_attendee_id: True} + ).exists() + def can_schedule_attendee(self, other_attendee_id): if str(self.id) == other_attendee_id: return True diff --git a/attendees/persons/models/attending.py b/attendees/persons/models/attending.py index a378bbbf..fa345da2 100644 --- a/attendees/persons/models/attending.py +++ b/attendees/persons/models/attending.py @@ -92,7 +92,7 @@ def meet_names(self): return ",".join([d.display_name for d in self.meets.all()]) @property - def attending_label(self): # parentheses needed in attendee_update_view.js for populateAttendingButtons? + def attending_label(self): # parentheses are no longer used since sorting by attendee name is needed. return f"{self.attendee.display_label} by {self.registration}" if self.registration else self.attendee.display_label @cached_property diff --git a/attendees/persons/models/folk_attendee.py b/attendees/persons/models/folk_attendee.py index 9b79ef2a..7322d09b 100644 --- a/attendees/persons/models/folk_attendee.py +++ b/attendees/persons/models/folk_attendee.py @@ -31,7 +31,7 @@ class FolkAttendee(TimeStampedModel, SoftDeletableModel): help_text="[Title] the family role of the attendee?", ) display_order = models.SmallIntegerField( - default=3000, + default=1, blank=False, null=False, db_index=True, @@ -86,7 +86,7 @@ class FolkAttendeesHistory(pghistory.get_event_model( created = model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created') modified = model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified') is_removed = models.BooleanField(default=False) - display_order = models.SmallIntegerField(default=3000, help_text='0 will be first family') + display_order = models.SmallIntegerField(default=1, help_text='0 will be first family') folk = models.ForeignKey(db_constraint=False, on_delete=models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='persons.folk') attendee = models.ForeignKey(db_constraint=False, on_delete=models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='persons.attendee') role = models.ForeignKey(db_constraint=False, help_text='[Title] the family role of the attendee?', on_delete=models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='persons.relation', verbose_name='attendee is') diff --git a/attendees/persons/serializers/attending_minimal_serializer.py b/attendees/persons/serializers/attending_minimal_serializer.py index cfa43706..3979a784 100644 --- a/attendees/persons/serializers/attending_minimal_serializer.py +++ b/attendees/persons/serializers/attending_minimal_serializer.py @@ -27,10 +27,17 @@ def create(self, validated_data): if "registration" in validated_data: registration_data = validated_data.pop("registration") - registration, created = Registration.objects.update_or_create( - id=None, - defaults=registration_data, - ) + filters = { + 'defaults': registration_data, + } + assembly = registration_data.get('assembly') + registrant = registration_data.get('registrant') + + if assembly: + filters['assembly'] = assembly + if registrant: + filters['registrant'] = registrant + registration, created = Registration.objects.update_or_create(**filters) validated_data["registration"] = registration obj, created = Attending.objects.update_or_create( id=None, @@ -41,14 +48,23 @@ def create(self, validated_data): def update(self, instance, validated_data): """ Update and return an existing `Attending` instance, given the validated data. - """ + if "registration" in validated_data: registration_data = validated_data.pop("registration") - registration, created = Registration.objects.update_or_create( - id=instance.registration.id if instance.registration else None, - defaults=registration_data, - ) + assembly = registration_data.get('assembly') + registrant = registration_data.get('registrant') + filters = { + 'defaults': registration_data, + } + if instance.registration: + filters['id'] = instance.registration.id + if assembly: + filters['assembly'] = assembly + if registrant: + filters['registrant'] = registrant + + registration, created = Registration.objects.update_or_create(**filters) validated_data["registration"] = registration obj, created = Attending.objects.update_or_create( diff --git a/attendees/persons/serializers/attendingmeet_etc_serializer.py b/attendees/persons/serializers/attendingmeet_etc_serializer.py index 3f5bde8e..ba4dcdc6 100644 --- a/attendees/persons/serializers/attendingmeet_etc_serializer.py +++ b/attendees/persons/serializers/attendingmeet_etc_serializer.py @@ -5,6 +5,7 @@ class AttendingMeetEtcSerializer(serializers.ModelSerializer): meet__assembly = serializers.IntegerField(read_only=True, source='assembly') # field name conversion for UI to group directly later + meet__assembly__display_order = serializers.IntegerField(read_only=True) registrant_attendee_id = serializers.CharField(read_only=True) attendee_id = serializers.CharField(read_only=True) attending__registration__registrant__infos__names__original = serializers.CharField(read_only=True, source='register_name') diff --git a/attendees/persons/services/attendee_service.py b/attendees/persons/services/attendee_service.py index 4fe34b20..66019c00 100644 --- a/attendees/persons/services/attendee_service.py +++ b/attendees/persons/services/attendee_service.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.aggregates.general import ArrayAgg, StringAgg from django.db.models import Case, F, Func, Q, When, CharField -from django.db.models.functions import Cast, Substr +from django.db.models.functions import Cast, Substr, Coalesce from django.db.models.expressions import OrderBy from django.http import Http404 from rest_framework.utils import json @@ -186,11 +186,11 @@ def by_datagrid_params( qs.select_related() .prefetch_related() .annotate( - attendingmeets=ArrayAgg('attendings__meets__slug', + attendingmeets=Coalesce(ArrayAgg('attendings__meets__slug', filter=Q(attendings__attendingmeet__finish__gte=now), - distinct=True), + distinct=True), []), # Prevents attendees without attendingmeets getting NULL and ruin order folkcities=StringAgg('folks__places__address__locality__name', - filter=(Q(folks__places__finish__isnull=True) | Q(folks__places__finish__gte=now)), + filter=(Q(folks__places__finish__isnull=True) | Q(folks__places__finish__gte=now)) & Q(folks__places__is_removed=False), delimiter=", ", distinct=True, default=None), @@ -199,10 +199,9 @@ def by_datagrid_params( delimiter=", ", distinct=True, default=None), - ) - .filter(final_query) + ).filter(final_query).distinct() .order_by(*orderby_list) - ).distinct() # when meets present duplicates appear + ) @staticmethod def orderby_parser(orderby_string, meets, current_user): @@ -238,9 +237,10 @@ def orderby_parser(orderby_string, meets, current_user): @staticmethod def filter_parser(filters_list, meets, current_user): """ - A recursive method return Q function based on multi-level filter conditions + A recursive method return Q function based on multi-level filter conditions. When filtering + for attendingmeet, it adds extra filters on attendingmeet__finish gte conditions. :param filters_list: a string of multi-level list of filter conditions - :param meets: assembly ids + :param meets: optional meet ids :param current_user: :return: Q function, could be an empty Q() """ @@ -252,6 +252,12 @@ def filter_parser(filters_list, meets, current_user): raise Exception( "Can't process both 'or'/'and' at the same level! please wrap them in separated lists." ) + elif ('!' in filters_list and and_string in filters_list) or ('!' in filters_list and or_string in filters_list): + raise Exception( + "Can't process 'or'/'and' with Unary filter(!) at the same level, please wrap them in separated lists." + ) + elif '!' == filters_list[0]: # currently only support one element for unary filter + return ~AttendeeService.filter_parser(filters_list[1], meets, current_user) elif filters_list[1] == and_string: and_list = [ element for element in filters_list if element != and_string @@ -277,33 +283,47 @@ def filter_parser(filters_list, meets, current_user): ) return or_query elif filters_list[1] == "=": - return Q( - **{ - AttendeeService.field_convert( - filters_list[0], meets, current_user - ): filters_list[2] - } - ) + condition = { + AttendeeService.field_convert( + filters_list[0], meets, current_user + ): filters_list[2], + } + if filters_list[0] == 'attendings__meets__slug' or (meets and Meet.objects.filter(id__in=meets, slug=filters_list[0], assembly__division__organization=current_user.organization).exists()): + condition['attendings__attendingmeet__finish__gte'] = datetime.now(timezone.utc) + + return Q(**condition) elif filters_list[1] == "startswith": - return Q( - **{ - AttendeeService.field_convert( - filters_list[0], meets, current_user - ) - + "__istartswith": filters_list[2] - } - ) + condition = { + AttendeeService.field_convert( + filters_list[0], meets, current_user + ) + + "__istartswith": filters_list[2] + } + if meets and Meet.objects.filter(id__in=meets, slug=filters_list[0], assembly__division__organization=current_user.organization).exists(): + condition['attendings__attendingmeet__finish__gte'] = datetime.now(timezone.utc) + return Q(**condition) elif filters_list[1] == "endswith": - return Q( - **{ - AttendeeService.field_convert( - filters_list[0], meets, current_user - ) - + "__iendswith": filters_list[2] - } - ) + condition = { + AttendeeService.field_convert( + filters_list[0], meets, current_user + ) + + "__iendswith": filters_list[2] + } + if meets and Meet.objects.filter(id__in=meets, slug=filters_list[0], assembly__division__organization=current_user.organization).exists(): + condition['attendings__attendingmeet__finish__gte'] = datetime.now(timezone.utc) + return Q(**condition) elif filters_list[1] == "contains": - return Q( + condition = { + AttendeeService.field_convert( + filters_list[0], meets, current_user + ) + + "__icontains": filters_list[2] + } + if meets and Meet.objects.filter(id__in=meets, slug=filters_list[0], assembly__division__organization=current_user.organization).exists(): + condition['attendings__attendingmeet__finish__gte'] = datetime.now(timezone.utc) + return Q(**condition) + elif filters_list[1] == "notcontains": + return ~Q( **{ AttendeeService.field_convert( filters_list[0], meets, current_user @@ -333,7 +353,7 @@ def field_convert(query_field, meets, current_user): } if meets: for meet in Meet.objects.filter( - id__in=meets, assembly__division__organization=current_user.organization + id__in=meets, assembly__division__organization=current_user.organization, ): field_converter[meet.slug] = "attendings__meets__display_name" diff --git a/attendees/persons/services/folk_service.py b/attendees/persons/services/folk_service.py index 95773b0c..bb42b93d 100644 --- a/attendees/persons/services/folk_service.py +++ b/attendees/persons/services/folk_service.py @@ -1,7 +1,7 @@ from collections import defaultdict - -from django.db.models import OuterRef, Subquery -from django.db.models.functions import Concat +from datetime import datetime, timezone +from django.contrib.postgres.aggregates.general import ArrayAgg +from django.db.models import Max, OuterRef, Q, Subquery from attendees.occasions.models import Meet from attendees.persons.models import Attendee, Folk, Utility, AttendingMeet @@ -13,102 +13,120 @@ def families_in_directory(directory_meet_id, member_meet_id, row_limit=26, targe """ It generates data for printing/previewing single or participating families in chosen divisions. The output includes indexes (families grouped in cities) and families (header and family member contacts). + An attendee will NOT be unique if it belongs to multiple families. Folkattendees role of masked won't be shown. + It has no scope checks, so callers need to limit the scope by passing targeting_attendee_id or divisions. + Phones/emails will be empty when there's no parents. For kids-only families please assign 'self' role to the first kid to show its phone/email """ families = [] index = defaultdict(lambda: {}) index_list = [] directory_meet = Meet.objects.filter(pk=directory_meet_id).first() member_meet = Meet.objects.filter(pk=member_meet_id).first() - targeting_families = Attendee.objects.get(pk=targeting_attendee_id).folks if targeting_attendee_id else Folk.objects.filter(division__in=divisions) + targeting_families = Attendee.objects.get(pk=targeting_attendee_id).folks if targeting_attendee_id else Folk.objects.filter(division__in=divisions).prefetch_related('attendees', 'folkattendee_set') if directory_meet: - attendee_subquery = Attendee.objects.filter(folks=OuterRef('pk')) # implicitly ordered at FolkAttendee model + attendee_subquery = Attendee.objects.filter( + attendings__attendingmeet__is_removed=False, + attendings__attendingmeet__meet=directory_meet, + attendings__attendingmeet__finish__gte=Utility.now_with_timezone(), + folks=OuterRef('pk'), + deathday=None, + is_removed=False, + ).order_by('folkattendee__display_order') + families_in_directory = targeting_families.annotate( - householder_name=Concat( - Subquery(attendee_subquery.values_list('last_name')[:1]), - Subquery(attendee_subquery.values_list('first_name')[:1]), - ) + householder_last_name=Subquery(attendee_subquery.values_list('last_name')[:1]), + householder_first_name=Subquery(attendee_subquery.values_list('first_name')[:1]), + householder_first_name2=Subquery(attendee_subquery.values_list('first_name2')[:1]), ).filter( category=Attendee.FAMILY_CATEGORY, is_removed=False, infos__print_directory=True, attendees__in=Attendee.objects.filter( attendings__in=directory_meet.attendings.filter( + attendingmeet__is_removed=False, attendingmeet__finish__gte=Utility.now_with_timezone() ), deathday=None, is_removed=False, ), - ).distinct().order_by('householder_name') + ).distinct().order_by('householder_last_name', 'householder_first_name', 'householder_first_name2') for family in families_in_directory: attrs = {} attendees = family.attendees.filter( deathday=None, attendings__in=directory_meet.attendings.filter( + attendingmeet__is_removed=False, attendingmeet__finish__gte=Utility.now_with_timezone() ), # only for attendees join the meet ).exclude( folkattendee__role__title="masked", # for joined attendees not to be shown in certain families - ).order_by('folkattendee__display_order') - parents = attendees.filter( - folkattendee__role__title__in=['self', 'spouse', 'husband', 'wife', 'father', 'mother', 'parent'] # no father/mother-in-law - ) - attrs['household_last_name'] = attendees.first().last_name - - phone1 = parents.first() and parents.first().infos.get('contacts', {}).get('phone1') # only phone1 published in directory - phone2 = None - if phone1: - attrs['phone1'] = Utility.phone_number_formatter(phone1) - email1 = parents.first() and parents.first().infos.get('contacts', {}).get('email1') # only email1 published in directory - if email1: - attrs['email1'] = email1 - - is_householder_member = member_meet and AttendingMeet.check_participation_of(attendees.first(), member_meet) - householder_title = f'{attendees.first().last_name}, {attendees.first().first_name}{"*" if is_householder_member else ""}' - name2_title = f'{attendees.first().name2()}' - if len(parents) > 1: - name2_title += f' {parents[1].name2()}' - is_parent1_member = member_meet and AttendingMeet.check_participation_of(parents[1], member_meet) - householder_title += f' & {parents[1].first_name}{"*" if is_parent1_member else ""}' - phone2 = parents[1].infos.get('contacts', {}).get('phone1') # only phone1 published in directory - if phone2 and phone1 != phone2: - attrs['phone2'] = Utility.phone_number_formatter(phone2) - email2 = parents[1].infos.get('contacts', {}).get('email1') # only email1 published in directory - if email2 and email1 != email2: - attrs['email2'] = email2 - attrs['household_title'] = householder_title - - family_address = family.places.first() and family.places.first().address # implicitly ordered by display_order of place - if family_address: - address_line1 = f'{family_address.street_number} {family_address.route}' - address_line2 = f'{family_address.locality.name}, {family_address.locality.state.code} {family_address.locality.postal_code}' - attrs['address_link'] = f'{address_line1}+{address_line2}'.replace(' ', '+') - if family_address.extra: - address_line1 += f' {family_address.extra}' - attrs['address_line1'] = address_line1 - attrs['address_line2'] = address_line2 - - index[family_address.locality.name][f'{householder_title} {name2_title}'.strip()] = Utility.phone_number_formatter(phone1 or phone2) - - attendees_attr = [] - for attendee in attendees: - is_attendee_member = member_meet and AttendingMeet.check_participation_of(attendee, member_meet) - attendees_attr.append({ - 'id': attendee.id, - 'first_name': f'{attendee.first_name}{"*" if is_attendee_member else ""}', - 'name2': attendee.name2(), - 'photo_url': attendee.photo and attendee.photo.url, - 'is_member': member_meet and AttendingMeet.check_participation_of(attendee, member_meet), - }) - attrs['attendees'] = attendees_attr - - families.append(attrs) + ).exclude( + folkattendee__finish__lte=datetime.now(timezone.utc) + ).distinct().order_by('folkattendee__display_order') + + if attendees: + parents = attendees.filter( + folkattendee__role__title__in=['self', 'spouse', 'husband', 'wife', 'father', 'mother', 'parent'] # no father/mother-in-law + ) + attrs['household_last_name'] = attendees.first().last_name + + if parents: + phone1 = parents.first() and parents.first().infos.get('contacts', {}).get('phone1') # only phone1 published in directory + phone2 = None + if phone1: + attrs['phone1'] = Utility.phone_number_formatter(phone1) + email1 = parents.first() and parents.first().infos.get('contacts', {}).get('email1') # only email1 published in directory + if email1: + attrs['email1'] = email1 + + is_householder_member = member_meet and AttendingMeet.check_participation_of(attendees.first(), member_meet) + householder_title = f'{attendees.first().last_name}, {attendees.first().first_name}{"*" if is_householder_member else ""}' + name2_title = f'{attendees.first().name2()}' + if len(parents) > 1: + name2_title += f' {parents[1].name2()}' + is_parent1_member = member_meet and AttendingMeet.check_participation_of(parents[1], member_meet) + householder_title += f' & {parents[1].first_name}{"*" if is_parent1_member else ""}' + phone2 = parents[1].infos.get('contacts', {}).get('phone1') # only phone1 published in directory + if phone2 and phone1 != phone2: + attrs['phone2'] = Utility.phone_number_formatter(phone2) + email2 = parents[1].infos.get('contacts', {}).get('email1') # only email1 published in directory + if email2 and email1 != email2: + attrs['email2'] = email2 + attrs['household_title'] = householder_title + + family_address = family.places.first() and family.places.first().address # implicitly ordered by display_order of place + if family_address: + address_line1 = f'{family_address.street_number} {family_address.route}' + address_line2 = f'{family_address.locality.name}, {family_address.locality.state.code} {family_address.locality.postal_code}' + attrs['address_link'] = f'{address_line1}+{address_line2}'.replace(' ', '+') + if family_address.extra: + address_line1 += f' {family_address.extra}' + attrs['address_line1'] = address_line1 + attrs['address_line2'] = address_line2 + + index[family_address.locality.name][f'{householder_title} {name2_title}'.strip()] = Utility.phone_number_formatter(phone1 or phone2) + + attendees_attr = [] + for attendee in attendees: + is_attendee_member = member_meet and AttendingMeet.check_participation_of(attendee, member_meet) + attendees_attr.append({ + 'id': attendee.id, + 'first_name': f'{attendee.first_name}{"*" if is_attendee_member else ""}', + 'name2': attendee.name2(), + 'photo_url': attendee.photo and attendee.photo.url, + 'is_member': member_meet and AttendingMeet.check_participation_of(attendee, member_meet), + }) + attrs['attendees'] = attendees_attr + + families.append(attrs) if targeting_attendee_id is None: - for town_name, family_rows in sorted(index.items()): - index_list.append({'BREAKER': 'LINE'}) + for i, (town_name, family_rows) in enumerate(sorted(index.items())): + if i > 0: # No line breaks before the first town + index_list.append({'BREAKER': 'LINE'}) if len(index_list) % row_limit < 1: index_list.append({'BREAKER': 'PAGE'}) @@ -124,11 +142,259 @@ def families_in_directory(directory_meet_id, member_meet_id, row_limit=26, targe return index_list, families @staticmethod - def families_in_participations(meet_id, current_user): + def families_in_participations(meet_slug, user_organization, show_paused, division_slugs): + """ + Returns a list of not-paused unique attendingmeet of a meet limited by user_organization and divisions, grouped + by families for print. If an Attendee belongs to many families, only 1) lowest display order 2) the last created + folkattendee will be shown. Attendees will NOT be shown if the category of the attendingmeet is "paused". + For cache computation final results may contain empty families so template need to filter them out. It does + NOT provide attendee counting, as view/template does css-counter or https://stackoverflow.com/a/34059709/4257237 + """ + families = {} # {family_pk: {family_name: "AAA", families: {attendee_pk: {first_name: 'XYZ', name2: 'ABC', rank: last_folkattendee_display_order, created_at: last_folkattendee_created_at}}}} + attendees_cache = {} # {attendee_pk: {last_family_pk: last_family_pk, rank: last_folkattendee_display_order, created_at: last_folkattendee_created_at}} + meet = Meet.objects.filter(slug=meet_slug, assembly__division__organization=user_organization).first() + if meet: + original_meets_attendings = meet.attendings.filter( + attendingmeet__is_removed=False, + attendingmeet__finish__gte=Utility.now_with_timezone(), + ) + meets_attendings = original_meets_attendings if show_paused else original_meets_attendings.exclude( + attendingmeet__category=Attendee.PAUSED_CATEGORY, + ) + original_attendee_subquery = Attendee.objects.filter( + attendings__attendingmeet__is_removed=False, + attendings__attendingmeet__meet=meet, + attendings__attendingmeet__finish__gte=Utility.now_with_timezone(), + folks=OuterRef('pk'), + deathday=None, + is_removed=False, + ).order_by('folkattendee__display_order') + + attendee_subquery = original_attendee_subquery if show_paused else original_attendee_subquery.exclude( + attendings__attendingmeet__category=Attendee.PAUSED_CATEGORY, + ) + + families_in_directory = Folk.objects.filter( + division__organization=user_organization, + ).prefetch_related('folkattendee_set', 'attendees').annotate( + householder_last_name=Subquery(attendee_subquery.values_list('last_name')[:1]), + householder_first_name=Subquery(attendee_subquery.values_list('first_name')[:1]), + householder_first_name2=Subquery(attendee_subquery.values_list('first_name2')[:1]), + ).filter( + category=Attendee.FAMILY_CATEGORY, + is_removed=False, + attendees__in=Attendee.objects.filter( + division__slug__in=division_slugs, + attendings__in=meets_attendings, + deathday=None, + is_removed=False, + ), + ).distinct().order_by('householder_last_name', 'householder_first_name', 'householder_first_name2') + + for family in families_in_directory: + candidates_qs = family.attendees.select_related('division', 'attendings', 'folkattendee_set').filter( + division__slug__in=division_slugs, + deathday=None, + attendings__in=meets_attendings, + ).exclude( + folkattendee__finish__lte=datetime.now(timezone.utc) + ) + + attendee_candidates = candidates_qs.distinct().order_by('folkattendee__display_order').values( + 'id', 'first_name', 'last_name', 'first_name2', 'last_name2', 'folkattendee__display_order', 'created', 'division__infos__acronym' + ).annotate( + attendingmeet_id=Max('attendings__attendingmeet__id', filter=Q(attendings__attendingmeet__meet=meet)), + attendingmeet_category=Max('attendings__attendingmeet__category', filter=Q(attendings__attendingmeet__meet=meet)), + attendingmeet_note=ArrayAgg('attendings__attendingmeet__infos__note', + filter=(Q(attendings__attendingmeet__meet=meet) & Q(attendings__attendingmeet__infos__note__isnull=False)), + distinct=True), + ) + + family_attrs = {"families": {}, 'family_name': 'no last names!'} + + if attendee_candidates[0]: + family_attrs['family_name'] = attendee_candidates[0].get('last_name') or attendee_candidates[0].get('last_name2') + + for attendee in attendee_candidates: + attendee_id = attendee.get('id') + attendee_last_record = attendees_cache.get(attendee_id) + if attendee_last_record: + current_rank = attendee.get('folkattendee__display_order') + last_rank = attendee_last_record.get('rank') + current_created = attendee.get('created') + last_created = attendee_last_record.get('created') + last_family = attendee_last_record.get('family_id') + if current_rank > last_rank or (current_rank == last_rank and current_created < last_created): + continue # unique by 1) lowest display order 2) the last created folkattendee + else: # current one will replace last one + del families[last_family]['families'][attendee_id] + if len(families[last_family]['families']) < 1: + del families[last_family] + elif str(attendee_id) == families[last_family].get('first_attendee_id'): + first_attendee = next(iter(families[last_family]['families'].items()))[1] + families[last_family]['first_attendee_id'] = str(first_attendee.get('id', '')) + + attendees_cache[attendee_id] = { + 'rank': attendee.get('folkattendee__display_order'), + 'created': attendee.get('created'), + 'family_id': family.id, + } + + family_attrs['families'][attendee_id] = { + 'id': attendee.get('id'), + 'first_name': attendee.get('first_name'), + 'first_name2': attendee.get('first_name2'), + 'last_name2': attendee.get('last_name2'), + 'division': attendee.get('division__infos__acronym'), + 'attendingmeet_id': attendee.get('attendingmeet_id'), + 'attendingmeet_category': attendee.get('attendingmeet_category'), + 'attendingmeet_note': ''.join(attendee.get('attendingmeet_note')), + 'paused': attendee.get('attendingmeet_category') == Attendee.PAUSED_CATEGORY, + } + + if len(family_attrs['families']) > 0: + first_attendee = next(iter(family_attrs['families'].items()))[1] + family_attrs['first_attendee_id'] = str(first_attendee.get('id', '')) + families[family.id] = family_attrs + + return families.values() + + @staticmethod + def folk_addresses_in_participations(meet_slug, user_organization, show_paused, division_slugs): """ - It generates printing data for attendances of a meet in current user's organization, grouped by families. + + Because attendee may be in multiple families and envelopes only needs the lowest display order ones, iteration + of attendee is required. + It's mostly copy from families_in_participations. """ - pass + families = {} # {family_pk: {family_name: "AAA", families: {attendee_pk: {first_name: 'XYZ', name2: 'ABC', rank: last_folkattendee_display_order, created_at: last_folkattendee_created_at}}}} + attendees_cache = {} # {attendee_pk: {last_family_pk: last_family_pk, rank: last_folkattendee_display_order, created_at: last_folkattendee_created_at}} + meet = Meet.objects.filter(slug=meet_slug, assembly__division__organization=user_organization).first() + if meet: + original_meets_attendings = meet.attendings.filter( + attendingmeet__is_removed=False, + attendingmeet__finish__gte=Utility.now_with_timezone(), + ) + meets_attendings = original_meets_attendings if show_paused else original_meets_attendings.exclude( + attendingmeet__category=Attendee.PAUSED_CATEGORY, + ) + original_attendee_subquery = Attendee.objects.filter( + attendings__attendingmeet__is_removed=False, + attendings__attendingmeet__meet=meet, + attendings__attendingmeet__finish__gte=Utility.now_with_timezone(), + folks=OuterRef('pk'), + is_removed=False, + deathday=None, + ).order_by('folkattendee__display_order') + + attendee_subquery = original_attendee_subquery if show_paused else original_attendee_subquery.exclude( + attendings__attendingmeet__category=Attendee.PAUSED_CATEGORY, + ) + + families_in_directory = Folk.objects.filter( + division__organization=user_organization, + ).prefetch_related('folkattendee_set', 'attendees', 'places').annotate( + householder_last_name=Subquery(attendee_subquery.values_list('last_name')[:1]), + householder_first_name=Subquery(attendee_subquery.values_list('first_name')[:1]), + householder_first_name2=Subquery(attendee_subquery.values_list('first_name2')[:1]), + ).filter( + category=Attendee.FAMILY_CATEGORY, + is_removed=False, + attendees__in=Attendee.objects.filter( + division__slug__in=division_slugs, + attendings__in=meets_attendings, + deathday=None, + is_removed=False, + ), + ).distinct().order_by('householder_last_name', 'householder_first_name', 'householder_first_name2') + + for family in families_in_directory: + candidates_qs = family.attendees.select_related('division', 'attendings', 'folkattendee_set').filter( + division__slug__in=division_slugs, + deathday=None, + attendings__in=meets_attendings, + ).exclude( + folkattendee__finish__lte=datetime.now(timezone.utc) + ) + + attendee_candidates = candidates_qs.distinct().order_by('folkattendee__display_order').values( + 'id', 'first_name', 'last_name', 'first_name2', 'last_name2', 'folkattendee__display_order', 'created', + ).annotate( + attendingmeet_category=Max('attendings__attendingmeet__category', filter=Q(attendings__attendingmeet__meet=meet)), + ) + + family_attrs = {'families': {}} + + for attendee in attendee_candidates: + attendee_id = attendee.get('id') + attendee_last_record = attendees_cache.get(attendee_id) + if attendee_last_record: + current_rank = attendee.get('folkattendee__display_order') + last_rank = attendee_last_record.get('rank') + current_created = attendee.get('created') + last_created = attendee_last_record.get('created') + last_family = attendee_last_record.get('family_id') + if current_rank > last_rank or (current_rank == last_rank and current_created < last_created): + continue # unique by 1) lowest display order 2) the last created folkattendee + else: # current one will replace last one + del families[last_family]['families'][attendee_id] + families_count = len(families[last_family]['families']) + if families_count < 1: + del families[last_family] + else: + families_iter = iter(families[last_family]['families'].items()) + home_head = next(families_iter)[1] + families[last_family]['recipient_attendee_id'] = str(home_head.get('id', '')) + families[last_family]['recipient_paused'] = home_head.get('paused') + families[last_family]['recipient_name'] = FolkService.get_recipient(home_head, None if families_count < 2 else next(families_iter)[1]) + + attendees_cache[attendee_id] = { + 'rank': attendee.get('folkattendee__display_order'), + 'created': attendee.get('created'), + 'family_id': family.id, + } + + family_attrs['families'][attendee_id] = { + 'id': attendee.get('id'), + 'first_name': attendee.get('first_name') or '', + 'first_name2': attendee.get('first_name2') or '', + 'last_name': attendee.get('last_name') or '', + 'last_name2': attendee.get('last_name2') or '', + 'paused': attendee.get('attendingmeet_category') == Attendee.PAUSED_CATEGORY, + } + + if len(family_attrs['families']) > 0: + families_iter = iter(family_attrs['families'].items()) + home_head = next(families_iter)[1] + + family_attrs['recipient_name'] = FolkService.get_recipient(home_head, None) + family_attrs['recipient_attendee_id'] = str(home_head.get('id', '')) + family_attrs['recipient_paused'] = home_head.get('paused') + folk_place = family.places.first() + + if len(family_attrs['families']) > 1: + family_attrs['recipient_name'] = FolkService.get_recipient(home_head, next(families_iter)[1]) + if folk_place: + address = family.places.first().address + if address: + family_attrs['address_line1'] = f"{address.street_number} {address.route} {address.extra or ''}".strip() + family_attrs['address_line2'] = f"{address.locality.name}, {address.locality.state.code} {address.locality.postal_code or ''}".strip() + + families[family.id] = family_attrs + + return families.values() + + @staticmethod + def get_recipient(home_head, spouse): + home_head_name2 = f"{home_head.get('last_name2', '')}{home_head.get('first_name2', '')}".strip() + if spouse: + spouse_name2 = f"{spouse.get('last_name2', '')}{spouse.get('first_name2', '')}".strip() + both_name = f"{' & '.join([i for i in [home_head.get('first_name', ''), spouse.get('first_name', '')] if i != ''])} {home_head.get('last_name', '')}" + both_name2 = f"{home_head_name2} {spouse_name2}".strip() + return f"{both_name} {both_name2}".strip() + else: + home_head_name = f"{home_head.get('first_name', '')} {home_head.get('last_name', '')}".strip() + return f"{home_head_name} {home_head_name2}".strip() @staticmethod def destroy_with_associations(folk, attendee): diff --git a/attendees/persons/signals.py b/attendees/persons/signals.py index 7afbde57..6d31507d 100644 --- a/attendees/persons/signals.py +++ b/attendees/persons/signals.py @@ -59,7 +59,7 @@ def post_save_handler_for_past_to_create_attendingmeet(sender, **kwargs): "meet": meet, "attending": first_attending, "is_removed": False, - "category_id": 6, # Active + # "category_id": 6, # Causing duplicates }, defaults=defaults, ) diff --git a/attendees/persons/urls.py b/attendees/persons/urls.py index 5103aba7..46a9dfb9 100644 --- a/attendees/persons/urls.py +++ b/attendees/persons/urls.py @@ -28,7 +28,10 @@ api_user_meet_attendings_viewset, api_family_organization_attendings_viewset, directory_report_list_view, + attendingmeet_report_list_view, + attendingmeet_envelopes_list_view, directory_print_configuration_view, + attendingmeet_print_configuration_view, person_directory_preview, ) @@ -179,11 +182,26 @@ view=directory_print_configuration_view, name="directory_print_configuration_view", ), + path( + "attendingmeet_print_configuration/", + view=attendingmeet_print_configuration_view, + name="attendingmeet_print_configuration_view", + ), path( "directory_report/", view=directory_report_list_view, name="directory_report_list_view", ), + path( + "attendingmeet_report/", + view=attendingmeet_report_list_view, + name="attendingmeet_report_list_view", + ), + path( + "attendingmeet_envelopes/", + view=attendingmeet_envelopes_list_view, + name="attendingmeet_envelopes_list_view", + ), path( "directory_preview/", view=person_directory_preview, diff --git a/attendees/persons/views/__init__.py b/attendees/persons/views/__init__.py index e5b015f4..898d3e21 100644 --- a/attendees/persons/views/__init__.py +++ b/attendees/persons/views/__init__.py @@ -24,5 +24,8 @@ from .api.organization_meet_character_attendings_for_attendingmeet import api_organization_meet_character_attendings_viewset_for_attendingmeet from .api.organization_meet_character_attendings_for_attendance import api_organization_meet_character_attendings_viewset_for_attendance from .page.directory_report_list_view import directory_report_list_view +from .page.attendingmeet_report_list_view import attendingmeet_report_list_view from .page.directory_print_configuration_view import directory_print_configuration_view +from .page.attendingmeet_print_configuration_view import attendingmeet_print_configuration_view +from .page.attendingmeet_envelopes_list_view import attendingmeet_envelopes_list_view from .page.person_directory_preview import person_directory_preview diff --git a/attendees/persons/views/api/all_registrations.py b/attendees/persons/views/api/all_registrations.py index 2e83556b..51992b45 100644 --- a/attendees/persons/views/api/all_registrations.py +++ b/attendees/persons/views/api/all_registrations.py @@ -19,12 +19,12 @@ def get_queryset(self): if registration_id: return Registration.objects.filter(pk=registration_id) else: + assembly = Utility.presence(self.request.query_params.get("assembly")) filters = { - "assembly": Utility.presence(self.request.query_params.get("assembly")), - "registrant": Utility.presence( - self.request.query_params.get("registrant") - ), + 'registrant': Utility.presence(self.request.query_params.get("registrant")), } # None is a valid value since it's null=True + if assembly: + filters['assembly'] = assembly return Registration.objects.filter(**filters) diff --git a/attendees/persons/views/api/attendee_attendings.py b/attendees/persons/views/api/attendee_attendings.py index 268fbf3a..083402b1 100644 --- a/attendees/persons/views/api/attendee_attendings.py +++ b/attendees/persons/views/api/attendee_attendings.py @@ -5,7 +5,7 @@ from rest_framework import viewsets from rest_framework.exceptions import PermissionDenied from django.conf import settings -from attendees.persons.models import Attendee, Attending +from attendees.persons.models import Attendee, Attending, Utility from attendees.persons.serializers.attending_minimal_serializer import ( AttendingMinimalSerializer, ) @@ -40,10 +40,18 @@ def get_queryset(self): or self.request.user.attendee.can_schedule_attendee(target_attendee.id) ): attending_id = self.kwargs.get("pk") - qs = Attending.objects.filter( - attendee=target_attendee, - attendee__division__organization=current_user_organization, - ) # With correct data this query will only work if current user's org is the same as targeting attendee's + filters = { + 'attendee': target_attendee, + 'attendee__division__organization': current_user_organization, + } # With correct data this query will only work if current user's org is the same as targeting attendee's + + registration__assembly = Utility.presence(self.request.query_params.get("registration__assembly")) + registration__registrant = Utility.presence(self.request.query_params.get("registration__registrant")) + if registration__assembly: + filters['registration__assembly'] = registration__assembly + if registration__registrant: + filters['registration__registrant'] = registration__registrant + qs = Attending.objects.filter(**filters) if attending_id: return qs.filter(pk=attending_id) @@ -65,14 +73,27 @@ def get_queryset(self): time.sleep(2) raise PermissionDenied(detail="Are you data admin or counselor?") + def perform_update(self, serializer): + target_attendee = get_object_or_404( + Attendee, pk=self.request.META.get("HTTP_X_TARGET_ATTENDEE_ID") + ) + if self.request.user.privileged_to_edit(target_attendee.id): + serializer.save() + target_attendee.save(update_fields=['modified']) + + else: + time.sleep(2) + raise PermissionDenied( + detail="Can't create attending across different organization" + ) + def perform_create(self, serializer): target_attendee = get_object_or_404( Attendee, pk=self.request.META.get("HTTP_X_TARGET_ATTENDEE_ID") ) - if target_attendee.under_same_org_with( - self.request.user.attendee and self.request.user.attendee.id - ): + if self.request.user.privileged_to_edit(target_attendee.id): serializer.save(attendee=target_attendee) + target_attendee.save(update_fields=['modified']) else: time.sleep(2) @@ -86,6 +107,7 @@ def perform_destroy(self, instance): ) if self.request.user.privileged_to_edit(target_attendee.id): AttendingService.destroy_with_associations(instance) + target_attendee.save(update_fields=['modified']) else: time.sleep(2) diff --git a/attendees/persons/views/api/attendee_folks.py b/attendees/persons/views/api/attendee_folks.py index 2f67c90f..66d19b39 100644 --- a/attendees/persons/views/api/attendee_folks.py +++ b/attendees/persons/views/api/attendee_folks.py @@ -54,11 +54,24 @@ def get_queryset(self): else: return attendee.folks.filter(extra_filter) + def perform_create( + self, serializer + ): # SpyGuard ensured requester & target_attendee belongs to the same org. + serializer.save() + target_attendee = get_object_or_404( + Attendee, pk=self.request.META.get("HTTP_X_TARGET_ATTENDEE_ID") + ) + target_attendee.save(update_fields=['modified']) + def perform_update(self, serializer): # Todo 20220706 respond for joining and families count instance = serializer.save() + target_attendee = get_object_or_404( + Attendee, pk=self.request.META.get("HTTP_X_TARGET_ATTENDEE_ID") + ) print_directory = self.request.META.get("HTTP_X_PRINT_DIRECTORY") and instance.category_id == 0 # family directory_meet_id = self.request.user.organization.infos.get('settings', {}).get('default_directory_meet') AttendingMeetService.flip_attendingmeet_by_existing_attending(self.request.user, instance.attendees.all(), directory_meet_id, print_directory) + target_attendee.save(update_fields=['modified']) def perform_destroy(self, instance): target_attendee = get_object_or_404( @@ -66,6 +79,7 @@ def perform_destroy(self, instance): ) if self.request.user.privileged_to_edit(target_attendee.id): FolkService.destroy_with_associations(instance, target_attendee) + target_attendee.save(update_fields=['modified']) else: time.sleep(2) diff --git a/attendees/persons/views/api/categorized_pasts.py b/attendees/persons/views/api/categorized_pasts.py index 9e670af0..d130dfd5 100644 --- a/attendees/persons/views/api/categorized_pasts.py +++ b/attendees/persons/views/api/categorized_pasts.py @@ -78,10 +78,38 @@ def get_queryset(self): else: # public return qs.exclude(category__display_name__in=[Past.COUNSELING, Past.COWORKER]) + def perform_destroy(self, instance): + if self.request.user.privileged_to_edit(self.request.META.get('HTTP_X_TARGET_ATTENDEE_ID')): # checked same org + subject = instance.subject + instance.delete() + subject.save(update_fields=['modified']) # Assuming subject is the Attendee only + else: + time.sleep(2) + raise PermissionDenied( + detail=f"Not allowed to delete {Past.__name__}" + ) + + def perform_update(self, serializer): + if self.request.user.privileged_to_edit(self.request.META.get('HTTP_X_TARGET_ATTENDEE_ID')): # checked same org + instance = serializer.save() + instance.subject.save(update_fields=['modified']) # Assuming subject is the Attendee only + else: + time.sleep(2) + raise PermissionDenied( + detail=f"Not allowed to update {Past.__name__}" + ) + def perform_create( self, serializer - ): # SpyGuard ensured requester & target_attendee belongs to the same org. - serializer.save(organization=self.request.user.organization) + ): # API can't rely on SpyGuard checking requester & target_attendee belongs to the same org. + if self.request.user.privileged_to_edit(self.request.META.get('HTTP_X_TARGET_ATTENDEE_ID')): # checked same org + instance = serializer.save(organization=self.request.user.organization) + instance.subject.save(update_fields=['modified']) # Assuming subject is the Attendee only + else: + time.sleep(2) + raise PermissionDenied( + detail=f"Not allowed to create {Past.__name__}" + ) api_categorized_pasts_viewset = ApiCategorizedPastsViewSet diff --git a/attendees/persons/views/api/datagrid_data_attendees.py b/attendees/persons/views/api/datagrid_data_attendees.py index daf842d2..d4a8d37d 100644 --- a/attendees/persons/views/api/datagrid_data_attendees.py +++ b/attendees/persons/views/api/datagrid_data_attendees.py @@ -1,4 +1,4 @@ -import ast +import json from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator @@ -32,9 +32,9 @@ def get_queryset(self): 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), + meets=json.loads(meets_string), orderby_string=orderby_string, - filters_list=ast.literal_eval(filters_list_string), + filters_list=json.loads(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/api/datagrid_data_attendingmeet.py b/attendees/persons/views/api/datagrid_data_attendingmeet.py index f172d41a..17582c8d 100644 --- a/attendees/persons/views/api/datagrid_data_attendingmeet.py +++ b/attendees/persons/views/api/datagrid_data_attendingmeet.py @@ -46,14 +46,25 @@ def get_queryset(self): Attendee, pk=self.request.META.get("HTTP_X_TARGET_ATTENDEE_ID") ) querying_attendingmeet_id = self.kwargs.get("pk") - qs = AttendingMeet.objects.annotate(assembly=F("meet__assembly"),).filter( - attending__attendee=target_attendee, + filters = {"attending__attendee": target_attendee} + if querying_attendingmeet_id: + filters['pk'] = querying_attendingmeet_id + qs = AttendingMeet.objects.annotate( + assembly=F("meet__assembly"), + meet__assembly__display_order=F('meet__assembly__display_order'), + ).filter(**filters) + + return qs.order_by( + 'meet__assembly__display_order', ) - if querying_attendingmeet_id: - return qs.filter(pk=querying_attendingmeet_id) - else: - return qs + def perform_create(self, serializer): + instance = serializer.save() + instance.attending.attendee.save(update_fields=['modified']) + + def perform_update(self, serializer): + instance = serializer.save() + instance.attending.attendee.save(update_fields=['modified']) def perform_destroy(self, instance): target_attendee = get_object_or_404( @@ -68,6 +79,7 @@ def perform_destroy(self, instance): attending=instance.attending ).delete() # delete only future attendance instance.delete() + target_attendee.save(update_fields=['modified']) else: time.sleep(2) raise PermissionDenied( diff --git a/attendees/persons/views/api/datagrid_data_folkattendees.py b/attendees/persons/views/api/datagrid_data_folkattendees.py index 4f2971ca..d99b2068 100644 --- a/attendees/persons/views/api/datagrid_data_folkattendees.py +++ b/attendees/persons/views/api/datagrid_data_folkattendees.py @@ -74,6 +74,11 @@ def get_queryset(self): def perform_update(self, serializer): # Todo 20220706 respond for joining and families count instance = serializer.save() + target_attendee = get_object_or_404( + Attendee, pk=self.request.META.get("HTTP_X_TARGET_ATTENDEE_ID") + ) + target_attendee.save(update_fields=['modified']) + instance.folk.save(update_fields=['modified']) Utility.add_update_attendee_in_infos(instance, self.request.user.attendee_uuid_str()) print_directory = instance.folk.infos.get('print_directory') and instance.folk.category_id == 0 # family directory_meet_id = self.request.user.organization.infos.get('settings', {}).get('default_directory_meet') @@ -81,6 +86,11 @@ def perform_update(self, serializer): # Todo 20220706 respond for joining and f def perform_create(self, serializer): instance = serializer.save() + target_attendee = get_object_or_404( + Attendee, pk=self.request.META.get("HTTP_X_TARGET_ATTENDEE_ID") + ) + target_attendee.save(update_fields=['modified']) + instance.folk.save(update_fields=['modified']) Utility.add_update_attendee_in_infos(instance, self.request.user.attendee_uuid_str()) print_directory = instance.folk.infos.get('print_directory') and instance.folk.category_id == 0 # family directory_meet_id = self.request.user.organization.infos.get('settings', {}).get('default_directory_meet') @@ -88,7 +98,13 @@ def perform_create(self, serializer): def perform_destroy(self, instance): # Todo 20221203 should it flip_attendingmeet_by_existing_attending? Utility.add_update_attendee_in_infos(instance, self.request.user.attendee_uuid_str()) + target_attendee = get_object_or_404( + Attendee, pk=self.request.META.get("HTTP_X_TARGET_ATTENDEE_ID") + ) + folk = instance.folk instance.delete() + target_attendee.save(update_fields=['modified']) + folk.save(update_fields=['modified']) api_datagrid_data_folkattendees_viewset = ApiDatagridDataFolkAttendeesViewsSet diff --git a/attendees/persons/views/api/organization_meet_character_attendingmeets.py b/attendees/persons/views/api/organization_meet_character_attendingmeets.py index b44b2881..9e05e156 100644 --- a/attendees/persons/views/api/organization_meet_character_attendingmeets.py +++ b/attendees/persons/views/api/organization_meet_character_attendingmeets.py @@ -31,6 +31,8 @@ def list(self, request, *args, **kwargs): search_value = json.loads(self.request.query_params.get("filter", "[[null]]"))[0][-1] queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) + start = self.request.query_params.get("start") + finish = self.request.query_params.get("finish") if page is not None: if group_column: @@ -38,6 +40,11 @@ def list(self, request, *args, **kwargs): Q(character__slug__in=request.query_params.getlist("characters[]", [])), Q.AND).add( Q(meet__assembly__division__organization=request.user.organization), Q.AND) + if start: + filters.add((Q(finish__isnull=True) | Q(finish__gte=start)), Q.AND) + if finish: + filters.add((Q(start__isnull=True) | Q(start__lte=finish)), Q.AND) + if search_value: filters.add((Q(attending__registration__registrant__infos__icontains=search_value) | @@ -104,6 +111,14 @@ def get_queryset(self): detail="Have you registered any events of the organization?" ) + def perform_create(self, serializer): + instance = serializer.save() + instance.attending.attendee.save(update_fields=['modified']) + + def perform_update(self, serializer): + instance = serializer.save() + instance.attending.attendee.save(update_fields=['modified']) + def perform_destroy(self, instance): allowed_groups = [group for group in instance.meet.infos.get('allowed_groups', []) if group != "organization_participant"] # intentionally forbid user to delete him/herself if self.request.user.belongs_to_groups_of(allowed_groups): @@ -112,7 +127,10 @@ def perform_destroy(self, instance): gathering__start__gte=Utility.now_with_timezone(), attending=instance.attending ).delete() # delete only future attendance + target_attendee = instance.attending.attendee instance.delete() + target_attendee.save(update_fields=['modified']) + else: time.sleep(2) raise PermissionDenied( diff --git a/attendees/persons/views/page/attendee_update_view.py b/attendees/persons/views/page/attendee_update_view.py index 5b6f3d72..7bad811f 100644 --- a/attendees/persons/views/page/attendee_update_view.py +++ b/attendees/persons/views/page/attendee_update_view.py @@ -1,4 +1,3 @@ -from json import dumps from time import sleep from django.conf import settings @@ -42,7 +41,7 @@ def get_context_data(self, **kwargs): important_meets = {v: int(k) for k, v in important_pasts.items()} context.update( { - "pasts_to_add": dumps({meet.display_name: important_meets.get(meet.id) for meet in Meet.objects.filter(pk__in=important_meets.keys()).order_by('created')}), + "pasts_to_add": {meet.display_name: important_meets.get(meet.id) for meet in Meet.objects.filter(pk__in=important_meets.keys()).order_by('created')}, "attendee_contenttype_id": ContentType.objects.get_for_model(Attendee).id, 'user_organization_directory_meet': self.request.user.organization.infos.get('settings', {}).get('default_directory_meet'), "teams_endpoint": "/occasions/api/organization_meet_teams/", @@ -68,13 +67,11 @@ def get_context_data(self, **kwargs): "family_attendees_endpoint": "/persons/api/datagrid_data_familyattendees/", "family_category_id": Attendee.FAMILY_CATEGORY, "targeting_attendee_id": targeting_attendee_id, - "grade_converter": dumps(self.request.user.organization.infos.get('grade_converter', []) if self.request.user.organization else []), - "divisions": dumps( - list( - Division.objects.filter( - organization=self.request.user.attendee.division.organization if hasattr(self.request.user, 'attendee') else self.request.user.organization, - ).values("id", "display_name", "infos") - ) + "grade_converter": self.request.user.organization.infos.get('grade_converter', []) if self.request.user.organization else [], + "divisions": list( + Division.objects.filter( + organization=self.request.user.attendee.division.organization if hasattr(self.request.user, 'attendee') else self.request.user.organization, + ).values("id", "display_name", "infos") ), # to avoid simultaneous AJAX calls "attendee_search": "/persons/api/datagrid_data_attendees/", "attendee_urn": "/persons/attendee/", diff --git a/attendees/persons/views/page/attendees_list_view.py b/attendees/persons/views/page/attendees_list_view.py index 68ee356c..c7b21604 100644 --- a/attendees/persons/views/page/attendees_list_view.py +++ b/attendees/persons/views/page/attendees_list_view.py @@ -1,5 +1,3 @@ -from json import dumps - from django.contrib.auth.decorators import login_required from django.db.models import F, Q from django.shortcuts import render @@ -38,7 +36,7 @@ def get_context_data(self, **kwargs): .annotate( assembly_name=F("assembly__display_name"), ) - .order_by("assembly_name") + .order_by("assembly__display_order", "assembly_name") .values("id", "slug", "display_name", "assembly_name", "infos__preview_url") ) # Todo 20210711 only coworkers can see all Meet, general users should only see what they attended allowed_to_create_attendee = Menu.user_can_create_attendee(self.request.user) @@ -47,7 +45,7 @@ def get_context_data(self, **kwargs): "family_attendances_urn": family_attendances_menu.urn if family_attendances_menu else None, - "available_meets_json": dumps(list(available_meets)), + "available_meets_json": list(available_meets), "allowed_to_create_attendee": allowed_to_create_attendee, "create_attendee_urn": "/persons/attendee/new", } diff --git a/attendees/persons/views/page/attendingmeet_envelopes_list_view.py b/attendees/persons/views/page/attendingmeet_envelopes_list_view.py new file mode 100644 index 00000000..e611c71c --- /dev/null +++ b/attendees/persons/views/page/attendingmeet_envelopes_list_view.py @@ -0,0 +1,48 @@ +from time import sleep +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.shortcuts import render +from django.utils.decorators import method_decorator +from django.views.generic import ListView + +from attendees.persons.services import FolkService +from attendees.users.authorization import RouteGuard + + +@method_decorator([login_required], name='dispatch') +class AttendingmeetEnvelopesListView(RouteGuard, ListView): + queryset = [] + template_name = "persons/attendingmeet_envelopes_list_view.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + show_paused = self.request.GET.get("showPaused") + families = FolkService.folk_addresses_in_participations( + meet_slug=self.request.GET.get("meet"), + user_organization=self.request.user.organization, + show_paused=show_paused, + division_slugs=self.request.GET.getlist('divisions', []), + ) if self.request.user.privileged() else [] + + context.update({ + 'report_titles': self.request.GET.get('reportTitle', '').split('\n'), + 'report_dates': self.request.GET.get('reportDate', '').split('\n'), + 'meet_slug': self.request.GET.get('meet', ''), + 'families': families, + 'newLines': range(int(self.request.GET.get('newLines', '2'))), + 'attendee_url': '/persons/attendee/', + }) + return context + + def render_to_response(self, context, **kwargs): + if self.request.user.privileged(): # data_admins and counselor + return render(self.request, self.get_template_names()[0], context) + else: + sleep(2) + return HttpResponse( + "Based on your organization's settings, you do not have permissions to visit this!", + status=403, + ) + + +attendingmeet_envelopes_list_view = AttendingmeetEnvelopesListView.as_view() diff --git a/attendees/persons/views/page/attendingmeet_print_configuration_view.py b/attendees/persons/views/page/attendingmeet_print_configuration_view.py new file mode 100644 index 00000000..35fb9883 --- /dev/null +++ b/attendees/persons/views/page/attendingmeet_print_configuration_view.py @@ -0,0 +1,30 @@ +import logging + +from django.contrib.auth.decorators import login_required +from django.shortcuts import render +from django.utils.decorators import method_decorator +from django.views.generic import ListView + +from attendees.users.authorization import RouteGuard + + +@method_decorator([login_required], name='dispatch') +class AttendingmeetPrintConfigurationView(RouteGuard, ListView): + queryset = [] + template_name = "persons/attendingmeet_print_configuration_view.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'pdf_url': '/persons/attendingmeet_report/', + 'envelopes_url': '/persons/attendingmeet_envelopes/', + "meets_endpoint_by_slug": "/occasions/api/organization_meets/", + "divisions_endpoint": "/whereabouts/api/user_divisions/", + }) + return context + + def render_to_response(self, context, **kwargs): + return render(self.request, self.get_template_names()[0], context) + + +attendingmeet_print_configuration_view = AttendingmeetPrintConfigurationView.as_view() diff --git a/attendees/persons/views/page/attendingmeet_report_list_view.py b/attendees/persons/views/page/attendingmeet_report_list_view.py new file mode 100644 index 00000000..06d9ffd4 --- /dev/null +++ b/attendees/persons/views/page/attendingmeet_report_list_view.py @@ -0,0 +1,51 @@ +from time import sleep +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.shortcuts import render +from django.utils.decorators import method_decorator +from django.views.generic import ListView + +from attendees.persons.models import Attendee +from attendees.persons.services import FolkService +from attendees.users.authorization import RouteGuard + + +@method_decorator([login_required], name='dispatch') +class AttendingmeetReportListView(RouteGuard, ListView): + queryset = [] + template_name = "persons/attendingmeet_report_list_view.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + show_paused = self.request.GET.get("showPaused") + families = FolkService.families_in_participations( + meet_slug=self.request.GET.get("meet"), + user_organization=self.request.user.organization, + show_paused=show_paused, + division_slugs=self.request.GET.getlist('divisions', []), + ) if self.request.user.privileged() else [] + + context.update({ + 'report_title': self.request.GET.get('reportTitle', ''), + 'report_date': self.request.GET.get('reportDate', ''), + 'meet_slug': self.request.GET.get('meet', ''), + 'families': families, + 'show_paused': show_paused, + 'attendee_url': '/persons/attendee/', + 'attendingmeet_url': '/persons/api/datagrid_data_attendingmeet/', + 'scheduled_category': Attendee.SCHEDULED_CATEGORY, + }) + return context + + def render_to_response(self, context, **kwargs): + if self.request.user.privileged(): # data_admins and counselor + return render(self.request, self.get_template_names()[0], context) + else: + sleep(2) + return HttpResponse( + "Based on your organization's settings, you do not have permissions to visit this!", + status=403, + ) + + +attendingmeet_report_list_view = AttendingmeetReportListView.as_view() diff --git a/attendees/persons/views/page/datagrid_assembly_all_attendings.py b/attendees/persons/views/page/datagrid_assembly_all_attendings.py index 9a70bb31..e01f7ddb 100644 --- a/attendees/persons/views/page/datagrid_assembly_all_attendings.py +++ b/attendees/persons/views/page/datagrid_assembly_all_attendings.py @@ -1,6 +1,5 @@ import logging -import time -from json import dumps +from time import sleep from django.contrib.auth.decorators import login_required from django.forms.models import model_to_dict @@ -38,19 +37,15 @@ def get_context_data(self, **kwargs): "current_division_slug": current_division_slug, "current_assembly_slug": current_assembly_slug, "available_meets": available_meets, - "available_meets_json": dumps( - [ - model_to_dict(m, fields=("slug", "display_name")) - for m in available_meets - ] - ), + "available_meets_json": [ + model_to_dict(m, fields=("slug", "display_name")) + for m in available_meets + ], "available_characters": available_characters, - "available_characters_json": dumps( - [ - model_to_dict(c, fields=("slug", "display_name")) - for c in available_characters - ] - ), + # "available_characters_json": [ + # model_to_dict(c, fields=("slug", "display_name")) + # for c in available_characters + # ], } ) return context @@ -115,7 +110,7 @@ def render_to_response(self, context, **kwargs): ) 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?") # def get_attendances(self, args): diff --git a/attendees/persons/views/page/datagrid_assembly_data_attendings.py b/attendees/persons/views/page/datagrid_assembly_data_attendings.py index b29faa6d..a6043295 100644 --- a/attendees/persons/views/page/datagrid_assembly_data_attendings.py +++ b/attendees/persons/views/page/datagrid_assembly_data_attendings.py @@ -1,6 +1,5 @@ import logging -import time -from json import dumps +from time import sleep from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin @@ -39,19 +38,15 @@ def get_context_data(self, **kwargs): "current_division_slug": current_division_slug, "current_assembly_slug": current_assembly_slug, "available_meets": available_meets, - "available_meets_json": dumps( - [ - model_to_dict(m, fields=("slug", "display_name")) - for m in available_meets - ] - ), + "available_meets_json": [ + model_to_dict(m, fields=("slug", "display_name")) + for m in available_meets + ], "available_characters": available_characters, - "available_characters_json": dumps( - [ - model_to_dict(c, fields=("slug", "display_name")) - for c in available_characters - ] - ), + "available_characters_json": [ + model_to_dict(c, fields=("slug", "display_name")) + for c in available_characters + ], } ) return context @@ -119,7 +114,7 @@ def render_to_response(self, context, **kwargs): ) 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?") # def get_attendances(self, args): diff --git a/attendees/persons/views/page/datagrid_attendingmeets_list_view.py b/attendees/persons/views/page/datagrid_attendingmeets_list_view.py index 2079c7c1..2a88174d 100644 --- a/attendees/persons/views/page/datagrid_attendingmeets_list_view.py +++ b/attendees/persons/views/page/datagrid_attendingmeets_list_view.py @@ -1,4 +1,4 @@ -import logging, json +import logging from django.contrib.auth.decorators import login_required from django.shortcuts import render @@ -20,7 +20,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update( { - "grade_converter": json.dumps(self.request.user.organization.infos.get('grade_converter', []) if self.request.user.organization else []), + "grade_converter": self.request.user.organization.infos.get('grade_converter', []) if self.request.user.organization else [], "user_can_write": MenuService.is_user_allowed_to_write(self.request), "assemblies_endpoint": "/occasions/api/user_assemblies/", "attendingmeets_endpoint": "/persons/api/organization_meet_character_attendingmeets/", diff --git a/attendees/persons/views/page/directory_report_list_view.py b/attendees/persons/views/page/directory_report_list_view.py index 81045dda..6a551ca4 100644 --- a/attendees/persons/views/page/directory_report_list_view.py +++ b/attendees/persons/views/page/directory_report_list_view.py @@ -1,7 +1,8 @@ import logging - +from time import sleep from django.conf import settings from django.contrib.auth.decorators import login_required +from django.http import HttpResponse from django.shortcuts import render from django.utils.decorators import method_decorator from django.views.generic import ListView @@ -30,7 +31,7 @@ def get_context_data(self, **kwargs): row_limit=index_row_per_page, targeting_attendee_id=None, divisions=Division.objects.filter(pk__in=division_ids, organization=self.request.user.organization), - ) + ) if self.request.user.privileged() else ([], []) # parenthesis are required context.update({ 'directory_header': self.request.GET.get('directoryHeader', ''), 'index_header': self.request.GET.get('indexHeader', ''), @@ -42,7 +43,14 @@ def get_context_data(self, **kwargs): return context def render_to_response(self, context, **kwargs): - return render(self.request, self.get_template_names()[0], context) + if self.request.user.privileged(): # data_admins and counselor + return render(self.request, self.get_template_names()[0], context) + else: + sleep(2) + return HttpResponse( + "Based on your organization's settings, you do not have permissions to visit this!", + status=403, + ) directory_report_list_view = DirectoryReportListView.as_view() diff --git a/attendees/static/css/attendingmeet_envelopes_list_view.css b/attendees/static/css/attendingmeet_envelopes_list_view.css new file mode 100644 index 00000000..15157886 --- /dev/null +++ b/attendees/static/css/attendingmeet_envelopes_list_view.css @@ -0,0 +1,47 @@ +@media print { + @page { + margin: 0.4in; + size: 9.5in 4.12in; /* COM 10 envelope landscape */ + } +} + +span.paused { + text-decoration: line-through; + color: SlateGrey; +} + +div.mono-space { + font-family: monospace; +} + +div.page-break { + display: block; + page-break-before: always; +} + +div.d-flex { + display: flex; +} + +div.sender { + flex-basis: 25rem; +} + +h6.center-text { + text-align: center; + width: 22rem; +} + +div.recipient:hover { + background: LightSkyBlue; + cursor: pointer; +} + +div.dark-background { + background-color: #E5E4E2; +} + +div.attendingmeet-envelope-container a.link-as-text { + text-decoration: none; + color: inherit; +} diff --git a/attendees/static/css/attendingmeet_report_list_view.css b/attendees/static/css/attendingmeet_report_list_view.css new file mode 100644 index 00000000..e789dd0d --- /dev/null +++ b/attendees/static/css/attendingmeet_report_list_view.css @@ -0,0 +1,108 @@ +@media print { + @page { + margin: 0.5in; + /*margin-top: 0.5in;*/ + /*margin-left: 2px;*/ + /*margin-bottom: 40px;*/ + /*margin-right: 2px;*/ + size: Letter; + @bottom-right { + font-family: Arial, sans-serif; + content: "Page " counter(page) " of " counter(pages); + } + } + /*@page :first {*/ + /* @top-right {*/ + /* content: "";*/ + /* }*/ + /*}*/ +} + +.paused { + text-decoration: line-through; + color: SlateGrey; +} + +div.folk-container a.link-as-text { + text-decoration: none; + color: inherit; +} + +.mono-space { + font-family: monospace; +} + +span.count { + counter-increment: member; +} + +h1.text-center { + width: 100%; + text-align: center; + font-size: calc(1.375rem + 1.5vw); + margin-top: 0; + margin-bottom: 0.5rem; + font-weight: 500; + line-height: 1.2; +} + +div.flex-justify-between { + display: flex; + justify-content: space-between; +} + +div.attendingmeet-report-container { + font-family: Arial, sans-serif; + font-size: 1rem; + font-weight: 400; + line-height: 1.6; + counter-reset: member; +} + +span.count::before { + content: counter(member); + width: 2em; + display: inline-block; + text-align: right; +} + +div.border-bottom { + border-bottom: 1px solid black; +} + +div.border-left { + border-left: 1px solid black; +} + +div.folk-container { + display: flex; +} + +div.family-name { + flex-basis: 15%; + padding: 8px; +} + +div.members-area { + flex-basis: 85%; +} + +div.members-container { + display: flex; + flex-wrap: wrap; +} + +div.member { + flex-basis: 50%; + padding: 8px; + margin-bottom: -1px; +} + +div.member:hover { + background: LightSkyBlue; + cursor: pointer; +} + +div.dark-background { + background-color: #E5E4E2; +} diff --git a/attendees/static/css/directory.css b/attendees/static/css/directory.css index 6e3e39ad..151bc08e 100644 --- a/attendees/static/css/directory.css +++ b/attendees/static/css/directory.css @@ -27,8 +27,8 @@ div.family-container { } div.family-title-container { - border-top: 1px solid; - border-bottom: 1px solid; + border-top: 1px solid black; + border-bottom: 1px solid black; display: flex; } diff --git a/attendees/static/js/occasions/datagrid_assembly_all_attendances.js b/attendees/static/js/occasions/datagrid_assembly_all_attendances.js index af13cfdb..201aaf90 100644 --- a/attendees/static/js/occasions/datagrid_assembly_all_attendances.js +++ b/attendees/static/js/occasions/datagrid_assembly_all_attendances.js @@ -209,7 +209,7 @@ Attendees.attendances = { }, // Getting html from Django upon user selecting meet(s) setDefaults: () => { - const locale = "en-us" + const locale = "en-us"; const dateOptions = { day: '2-digit', month: '2-digit', year: 'numeric' }; const timeOptions = { hour12: true, hour: '2-digit', minute:'2-digit' }; const defaultFilterStartDate = new Date(); diff --git a/attendees/static/js/occasions/roster_list_view.js b/attendees/static/js/occasions/roster_list_view.js index e958568c..35de77e3 100644 --- a/attendees/static/js/occasions/roster_list_view.js +++ b/attendees/static/js/occasions/roster_list_view.js @@ -15,6 +15,7 @@ Attendees.roster = { console.log('static/js/occasions/roster_list_view.js'); Attendees.roster.initFiltersForm(); Attendees.roster.initCheckOutPopup(); + Attendees.roster.gradeConverter = JSON.parse(document.getElementById('organization-grade-converter').textContent); }, updateAttendance: (event) => { @@ -1027,6 +1028,27 @@ Attendees.roster = { } }, }, + { + dataField: 'attending__attendee__infos__fixed__grade', + dataHtmlTitle: 'hold the "Shift" key and click to apply sorting, hold the "Ctrl" key and click to cancel sorting.', + caption: 'Grade', + visible: false, + cellTemplate: (cellElement, cellInfo) => { + if (cellInfo.displayValue) { + cellElement.append ('' + Attendees.roster.gradeConverter[parseInt(cellInfo.displayValue)] + ''); + } + } + }, + { + dataField: 'attending__attendee__infos__fixed__food_pref', + allowGrouping: false, + visible: false, + caption: 'Food pref', + dataType: 'string', + editorOptions: { + autoResizeEnabled: true, + }, + }, { dataField: 'infos.note', caption: 'Note', diff --git a/attendees/static/js/persons/attendee_update_view.js b/attendees/static/js/persons/attendee_update_view.js index 523798ff..7a900318 100644 --- a/attendees/static/js/persons/attendee_update_view.js +++ b/attendees/static/js/persons/attendee_update_view.js @@ -29,7 +29,7 @@ Attendees.datagridUpdate = { // assembly: parseInt(document.querySelector('div.datagrid-attendee-update').dataset.currentAssemblyId), category: 1, // scheduled start: new Date().toISOString(), - finish: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString(), // 1 years from now + finish: new Date(new Date().setFullYear(new Date().getFullYear() + 20)).toISOString(), // 20 years from now }, // addressId: '', // for sending address data by AJAX divisionShowAttendeeInfos: {}, @@ -154,8 +154,8 @@ Attendees.datagridUpdate = { initAttendeeForm: () => { Attendees.datagridUpdate.attendeeAttrs = document.querySelector('div.datagrid-attendee-update'); - Attendees.datagridUpdate.gradeConverter = JSON.parse(Attendees.datagridUpdate.attendeeAttrs.dataset.gradeConverter).reduce((all, now, index) =>{all.push({id: index, label: now}); return all}, []); - Attendees.datagridUpdate.pastsToAdd = JSON.parse(Attendees.datagridUpdate.attendeeAttrs.dataset.pastsToAdd); + Attendees.datagridUpdate.gradeConverter = JSON.parse(document.getElementById('organization-grade-converter').textContent).reduce((all, now, index) =>{all.push({id: index, label: now}); return all}, []); // for dxSelectBox + Attendees.datagridUpdate.pastsToAdd = JSON.parse(document.getElementById('organization-pasts-to-add').textContent); Attendees.datagridUpdate.pastsCategories = new Set(Object.values(Attendees.datagridUpdate.pastsToAdd)); Attendees.datagridUpdate.processDivisions(); Attendees.datagridUpdate.divisionIdNames = Attendees.datagridUpdate.divisions.reduce((obj, item) => ({...obj, [item.id]: item.display_name}) ,{}); @@ -211,7 +211,7 @@ Attendees.datagridUpdate = { }, processDivisions: () => { - Attendees.datagridUpdate.divisions = JSON.parse(Attendees.datagridUpdate.attendeeAttrs.dataset.divisions); + Attendees.datagridUpdate.divisions = JSON.parse(document.getElementById('user-organization-divisions').textContent); Attendees.datagridUpdate.divisionShowAttendeeInfos = Object.entries(Attendees.datagridUpdate.divisions).reduce((acc, curr) => { const [key, value] = curr; acc[value.id] = value.infos.show_attendee_infos || {}; @@ -1099,15 +1099,21 @@ Attendees.datagridUpdate = { attendings.forEach(attending => { if (attending && attending.attending_id) { - let label, title; - if (attending.attending_label){ - const originalLabel = (attending.attending_label).match(/\(([^)]+)\)/).pop(); // get substring between parentheses + let label, title, text_between_parentheses = attending.attending_label && (attending.attending_label).match(/\(([^)]+)\)/); // get substring between parentheses + if (attending.attending_label && text_between_parentheses){ + const originalLabel = text_between_parentheses.pop(); label = originalLabel.replace(Attendees.datagridUpdate.attendeeFormConfigs.formData.infos.names.original, 'Self'); title = label; - } else { + } else if (attending.registrant) { // registrant is nullable const registrant_name = attending.registrant.replace(Attendees.datagridUpdate.attendeeFormConfigs.formData.infos.names.original, 'Self'); label = attending.registration_assembly ? registrant_name + ' ' + attending.registration_assembly : registrant_name + ' Generic'; title = attending.registrant; + } else if (attending.attending_label) { + label = attending.attending_label.replace(/\s+/g, ' '); // replace multiple space + title = label; + } else if (!attending.registrant) { + label = 'Self' + title = label; } $(' + {% endif %} {% translate "Forgot Password?" %} diff --git a/attendees/templates/base.html b/attendees/templates/base.html index 4d4523bd..631f32f1 100644 --- a/attendees/templates/base.html +++ b/attendees/templates/base.html @@ -91,9 +91,8 @@ - - + + {{ user_api_allowed_url_name|json_script:"user-api-allowed-url-names" }}