diff --git a/.env.example b/.env.example index 4a9f11a4..f80f5534 100644 --- a/.env.example +++ b/.env.example @@ -60,3 +60,5 @@ DATABASE_URL=postgres://postgres_user:postgres_password@database/database_name POSTGRES_USER=postgres_user POSTGRES_PASSWORD=postgres_password POSTGRES_DB=database_name + +DATA_UPLOAD_MAX_NUMBER_FIELDS = 10240 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9c08960e..6fbf3b25 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,7 +4,7 @@ You are more than welcome to contribute to the system. This guide documents how ## Getting a local setup -- Installing Docker: Download and install [docker-compose][docker-guide]. If +- Installing Docker: Download and install [Docker Compose][docker-guide]. If you use Ubuntu 18.04 (LTS), you can use [this guide][docker-ubuntu-guide] to set up Docker. @@ -13,15 +13,16 @@ You are more than welcome to contribute to the system. This guide documents how - The setup adheres to the [twelve-factor-app][12f] principles. To get a local development configuration, copy the file `.env.example` to `.env` -- Run `docker-compose up` to start your local system. +- Run `docker compose up` to start your local system. + (If on Apple Silicon machine, run `docker compose -f docker-compose.yml -f docker-compose.arm64.yml up --build`) -- Run `docker-compose run web ./manage.py get_live_data` to download public +- Run `docker compose run web ./manage.py get_live_data` to download public data and insert it into your local database. - To get some dummy members, families, etc. you can use the [factories][factories] to create them. ```bash - docker-compose run web ./manage.py shell + docker compose run web ./manage.py shell from members.tests.factories import MemberFactory MemberFactory.create_batch(20) ``` @@ -31,7 +32,7 @@ You are more than welcome to contribute to the system. This guide documents how with the real world. For instance each member belongs to their own department. - To create a super user for the admin interface you can run - `docker-compose run web ./manage.py createsuperuser` + `docker compose run web ./manage.py createsuperuser` - A pgAdmin container is configured as part of Docker Compose, and can be accessed on . Log in with credentials `admin@example.com`/`admin`. Connection to database has been configured in @@ -65,15 +66,15 @@ You are more than welcome to contribute to the system. This guide documents how - [Django][django]: The base web framework used. The link is to their great tutorial which takes an hour or two to complete. -- [Docker][docker-tutorial]: We use `docker-compose` to setup database, +- [Docker][docker-tutorial]: We use `docker compose` to setup database, environment and dependencies. The following commands is all that's required to work on the system. - - `docker-compose build` -- Builds the system. - - `docker-compose up` -- Starts the systems. - - `docker-compose down && docker volume rm backend_database` + - `docker compose build` -- Builds the system. + - `docker compose up` -- Starts the systems. + - `docker compose down && docker volume rm backend_database` \-- Deletes your local database - - `docker-compose run web command` -- Replace `command` with what you want + - `docker compose run web command` -- Replace `command` with what you want to run in the system. - [SASS][sass]: CSS files belong in `members/static/members/sass`, @@ -82,7 +83,7 @@ You are more than welcome to contribute to the system. This guide documents how following command in a separate terminal: ```bash - docker-compose run web node_modules/.bin/sass --watch members/static/members/sass/main.scss members/static/members/css/main.css + docker compose run web node_modules/.bin/sass --watch members/static/members/sass/main.scss members/static/members/css/main.css ``` It will compile SASS when you save a file. @@ -94,7 +95,7 @@ You are more than welcome to contribute to the system. This guide documents how - [Selenium][selenium]: runs the functional tests. To run a specific test run ```bash - docker-compose run web ./manage.py test members.tests.test_functional.test_create_family + docker compose run web ./manage.py test members.tests.test_functional.test_create_family ``` where the name of your tests replaces the last part. @@ -102,7 +103,7 @@ You are more than welcome to contribute to the system. This guide documents how - [Unit tests][unittest]: runs the unittests. You run the unit tests the same way as the selenium tests. To run a specific test run ```bash - docker-compose run web ./manage.py test members.tests.test_dump_data + docker compose run web ./manage.py test members.tests.test_dump_data ``` where the name of your tests replaces the last part. @@ -122,7 +123,7 @@ Pragmatic development is to use docker for database and run server and/or tests - Install npm dependencies: `npm install` - Copy the sample environment file: `cp .env.example .env` -- boot the database with `docker-compose start database` +- boot the database with `docker compose start database` - boot a selenium docker with `docker run -it -p 4444:4444 -p 7900:7900 --network="host" -v /dev/shm:/dev/shm selenium/standalone-chrome` - start the virtual env shell and work from there further on with `poetry shell` - Run sass: `./node_modules/.bin/sass members/static/members/sass/main.scss` @@ -159,13 +160,13 @@ p.user.username # show login email discussion happens before the code and limits duplicate work. 4. Help us specify the requirements specification. 5. Code the features with tests, see the [testing guide][test_guide] -6. Run the entire test suite with: `docker-compose run web ./manage.py test` +6. Run the entire test suite with: `docker compose run web ./manage.py test` 7. Check that the following requirements are meet: - The code has tests, code without tests is not accepted. (Except for minimal CSS and text changes). Use the existing test as inspiration and the [factories][factories] to create dummy data. - The code conforms to the [black][black] formatting rules. To format your - code run `docker-compose run web black .`. Consider looking for an + code run `docker compose run web black .`. Consider looking for an editor integration. - The code passes [flake8][flake8] checks. 8. Submit the pull request. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c44e7e3..8974e87c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,21 +11,21 @@ jobs: - name: Setup Enviroment run: cp .env.example .env - name: Builds the stack - run: docker-compose build + run: docker compose build - name: Check formatting (Black) - run: docker-compose run web black --check . + run: docker compose run web black --check . - name: Static error check (Flake8) - run: docker-compose run web flake8 + run: docker compose run web flake8 - name: Test - run: docker-compose run web ./manage.py test - - uses: actions/upload-artifact@v2.1.4 + run: docker compose run web ./manage.py test + - uses: actions/upload-artifact@v4.4.0 if: always() with: name: selenium-screens path: ./test-screens - name: Create and upload UML diagram - run: mkdir -p UML && docker-compose run web ./manage.py graph_models members -o UML/UML_diagram.png - - uses: actions/upload-artifact@v2.1.4 + run: mkdir -p UML && docker compose run web ./manage.py graph_models members -o UML/UML_diagram.png + - uses: actions/upload-artifact@v4.4.0 with: name: UML_diagram.png path: UML diff --git a/docker-compose.arm64.yml b/docker-compose.arm64.yml new file mode 100644 index 00000000..4f975e7f --- /dev/null +++ b/docker-compose.arm64.yml @@ -0,0 +1,11 @@ +# use this file as override, if on Arm64 architecture, e.g. Mac M1 +# example commands: +# - docker compose -f docker-compose.yml -f docker-compose.arm64.yml up --build +# - docker compose -f docker-compose.yml -f docker-compose.arm64.yml run --build web ./manage.py test +services: + selenium: + image: seleniarm/standalone-chromium + networks: + - webnet + ports: + - "4444:4444" diff --git a/docker-compose.yml b/docker-compose.yml index 94630806..45117fe0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.3" - services: web: build: . diff --git a/forenings_medlemmer/settings.py b/forenings_medlemmer/settings.py index 98d7818d..f2a3eafa 100644 --- a/forenings_medlemmer/settings.py +++ b/forenings_medlemmer/settings.py @@ -99,6 +99,8 @@ CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" CRISPY_TEMPLATE_PACK = "bootstrap5" +DATA_UPLOAD_MAX_NUMBER_FIELDS = int(os.environ["DATA_UPLOAD_MAX_NUMBER_FIELDS"]) + MIDDLEWARE = ( "django.middleware.security.SecurityMiddleware", "corsheaders.middleware.CorsMiddleware", @@ -229,6 +231,8 @@ SECURE_SSL_REDIRECT = env.bool("FORCE_HTTPS") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True LOGIN_URL = "/account/login/" diff --git a/members/admin/__init__.py b/members/admin/__init__.py index 4399cf20..a408b91f 100644 --- a/members/admin/__init__.py +++ b/members/admin/__init__.py @@ -11,12 +11,14 @@ EmailTemplate, Equipment, Family, + Municipality, Payment, Person, Union, VolunteerRequest, VolunteerRequestDepartment, WaitingList, + EmailItem, ) from .activity_admin import ActivityAdmin @@ -26,6 +28,7 @@ from .department_admin import DepartmentAdmin from .equipment_admin import EquipmentAdmin from .family_admin import FamilyAdmin +from .municipality_admin import MunicipalityAdmin from .payment_admin import PaymentAdmin from .person_admin import PersonAdmin from .union_admin import UnionAdmin @@ -33,6 +36,7 @@ from .volunteerrequest_admin import VolunteerRequestAdmin from .volunteerrequestdepartment_admin import VolunteerRequestDepartmentAdmin from .waitinglist_admin import WaitingListAdmin +from .emailitem_admin import EmailItemAdmin admin.site.site_header = "Coding Pirates Medlemsdatabase" admin.site.index_title = "Afdelings admin" @@ -45,12 +49,14 @@ admin.site.register(EmailTemplate) admin.site.register(Equipment, EquipmentAdmin) admin.site.register(Family, FamilyAdmin) +admin.site.register(Municipality, MunicipalityAdmin) admin.site.register(Payment, PaymentAdmin) admin.site.register(Person, PersonAdmin) admin.site.register(Union, UnionAdmin) admin.site.register(WaitingList, WaitingListAdmin) admin.site.register(VolunteerRequest, VolunteerRequestAdmin) admin.site.register(VolunteerRequestDepartment, VolunteerRequestDepartmentAdmin) +admin.site.register(EmailItem, EmailItemAdmin) admin.site.unregister(User) admin.site.register(User, UserAdmin) # admin.site.register(AdminUserInformation, AdminUserInformationAdmin) diff --git a/members/admin/activity_admin.py b/members/admin/activity_admin.py index 9eec2aea..0c7547e1 100644 --- a/members/admin/activity_admin.py +++ b/members/admin/activity_admin.py @@ -1,13 +1,20 @@ -from django.contrib import admin +from django.contrib import admin, messages +from django.conf import settings +from django.core.exceptions import ValidationError from django.urls import reverse from django.utils.safestring import mark_safe -from django.utils.html import escape +from django.utils.html import escape, format_html from members.models import ( ActivityParticipant, AdminUserInformation, Department, Union, + Address, +) + +from .inlines import ( + EmailItemInline, ) from members.admin.admin_actions import AdminActions @@ -86,8 +93,6 @@ def queryset(self, request, queryset): class ActivityAdmin(admin.ModelAdmin): list_display = ( "name", - "union_link", - "department_link", "activitytype", "start_end", "open_invite", @@ -96,6 +101,8 @@ class ActivityAdmin(admin.ModelAdmin): "seats_used", "seats_free", "age", + "union_link", + "department_link", ) date_hierarchy = "start_date" @@ -105,7 +112,12 @@ class ActivityAdmin(admin.ModelAdmin): "department__name", "description", ) - readonly_fields = ("seats_left", "participants") + readonly_fields = ( + "seats_left", + "participants", + "activity_link", + "addressregion", + ) list_per_page = 20 raw_id_fields = ( "union", @@ -116,16 +128,24 @@ class ActivityAdmin(admin.ModelAdmin): ActivityDepartmentListFilter, "open_invite", "activitytype", + "address__region", + ) + autocomplete_fields = ( + "address", + "department", ) actions = [ AdminActions.export_participants_csv, ] save_as = True + ordering = ("-start_date", "department__name", "name") + class Media: css = {"all": ("members/css/custom_admin.css",)} # Include extra css + js = ("members/js/copy_to_clipboard.js",) - inlines = [ActivityParticipantInline] + inlines = [ActivityParticipantInline, EmailItemInline] def start_end(self, obj): return str(obj.start_date) + " - " + str(obj.end_date) @@ -171,6 +191,11 @@ def seats_free(self, obj): seats_free.short_description = "Ubesat" + def addressregion(self, obj): + return str(obj.address.region) + + addressregion.short_description = "Region" + def activity_membership_union_link(self, obj): if obj.activitytype_id in ["FORENINGSMEDLEMSKAB", "STØTTEMEDLEMSKAB"]: url = reverse("admin:members_union_change", args=[obj.union_id]) @@ -181,6 +206,25 @@ def activity_membership_union_link(self, obj): activity_membership_union_link.short_description = "Forening for medlemskab" + def activity_link(self, obj): + if obj.id is None: + return "" + + full_url = ( + f"{settings.BASE_URL}{reverse('activity_view_family', args=[obj.id])}" + ) + link = format_html( + '{} ' + '', + full_url, + full_url, + full_url, + ) + + return mark_safe(link) + + activity_link.short_description = "Link til aktivitet" + # Only view activities on own department def get_queryset(self, request): qs = super(ActivityAdmin, self).get_queryset(request) @@ -191,8 +235,10 @@ def get_queryset(self, request): departments = Department.objects.filter(adminuserinformation__user=request.user) return qs.filter(department__in=departments) - # Only show own departments when creating new activity + # Solution found on https://stackoverflow.com/questions/57056994/django-model-form-with-only-view-permission-puts-all-fields-on-exclude + # formfield_for_foreignkey described in documentation here: https://docs.djangoproject.com/en/4.2/ref/contrib/admin/#django.contrib.admin.ModelAdmin.formfield_for_foreignkey def formfield_for_foreignkey(self, db_field, request, **kwargs): + # Only show own departments when creating new activity if ( db_field.name == "department" and not request.user.is_superuser @@ -201,26 +247,45 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): kwargs["queryset"] = Department.objects.filter( adminuserinformation__user=request.user ) - return super(ActivityAdmin, self).formfield_for_foreignkey( - db_field, request, **kwargs - ) + if db_field.name == "address": + kwargs["queryset"] = Address.get_user_addresses(request.user) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def delete_queryset(self, request, queryset): + for activity in queryset: + print(activity) + self.delete_model(request, activity) + + def delete_model(self, request, activity): + try: + activity.delete() + messages.success(request, f'Aktivitet "{activity.name}" slettet.') + except ValidationError as e: + messages.error(request, e.message) + + except Exception as e: + messages.error(request, f"Fejl: {str(e)}") fieldsets = [ ( "Afdeling", { - "description": "

Du kan ændre afdeling for aktiviteten ved at skrive afdelings-id, eller tryk på søg-ikonet og i det nye vindue skal du finde afdelingen, for derefter at trykke på ID i første kolonne.

", + "description": "

Du kan ændre afdeling for aktiviteten ved at vælge en afdeling i listen, evt bruge søgefunktionen.

", "fields": ("department",), }, ), ( "Aktivitet", { - "description": "

Aktivitetsnavnet skal afspejle aktivitet samt tidspunkt. F.eks. Forårssæson 2018.

Tidspunkt er f.eks. Onsdage 17:00-19:00

", + "description": """

Aktivitetsnavnet skal afspejle aktivitet samt tidspunkt. + F.eks. Forårssæson 2018.

+

Tidspunkt er f.eks. Onsdage 17:00-19:00

+

Startdato er første dag for aktiviteten, og slutdato er sidste for aktiviteten

""", "fields": ( ( "name", "activitytype", + "activity_link", ), "open_hours", "description", @@ -229,36 +294,38 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): "end_date", ), "member_justified", + "visible", + "visible_from", ), }, ), ( "Lokation og ansvarlig", { - "description": "

Adresse samt ansvarlig kan adskille sig fra afdelingens informationer (f.eks. et gamejam der foregår et andet sted).

", + "description": """

Adresse samt ansvarlig kan adskille sig fra afdelingens + informationer (f.eks. et gamejam der kan foregå et andet sted).

""", "fields": ( - ( - "responsible_name", - "responsible_contact", - ), - ( - "streetname", - "housenumber", - "floor", - "door", - ), - ( - "zipcode", - "city", - "placename", - ), + "address", + "addressregion", + "responsible_name", + "responsible_contact", ), }, ), ( "Tilmeldingsdetaljer", { - "description": '

Tilmeldingsinstruktioner er tekst der kommer til at stå på betalingsformularen på tilmeldingssiden. Den skal bruges til at stille spørgsmål, som den, der tilmelder sig, kan besvare ved tilmelding.

Fri tilmelding betyder, at alle, når som helst kan tilmelde sig denne aktivitet - efter "først til mølle"-princippet. Dette er kun til aktiviteter og klubaften-forløb/sæsoner i områder, hvor der ikke er nogen venteliste.

Alle aktiviteter med fri tilmelding kommer til at stå med en stor "tilmeld" knap på medlemssiden. Vi bruger typisk ikke fri tilmelding - spørg i Slack hvis du er i tvivl!

', + "description": """

Tilmeldingsinstruktioner er tekst der kommer til at stå på + betalingsformularen på tilmeldingssiden.

+

Den skal bruges til at stille spørgsmål, som den, der tilmelder sig, + kan besvare ved tilmelding.

+

Fri tilmelding betyder, at alle, når som helst kan tilmelde sig denne + aktivitet - efter "først til mølle"-princippet. + Dette er kun til aktiviteter og klubaften-forløb/sæsoner i områder, + hvor der ikke er nogen venteliste.

+

Alle aktiviteter med fri tilmelding kommer til at stå med en stor "tilmeld" + knap på medlemssiden. Afdelinger med venteliste bruger typisk ikke fri + tilmelding - spørg i Slack hvis du er i tvivl!

""", "fields": ( "instructions", ( diff --git a/members/admin/activityinvite_admin.py b/members/admin/activityinvite_admin.py index 250fdec2..24deb6b0 100644 --- a/members/admin/activityinvite_admin.py +++ b/members/admin/activityinvite_admin.py @@ -1,14 +1,23 @@ +import codecs +from datetime import timedelta from django import forms from django.contrib import admin +from django.contrib import messages +from django.contrib.admin.widgets import AdminDateWidget +from django.db import transaction from django.db.models.functions import Lower +from django.http import HttpResponse +from django.shortcuts import render from django.urls import reverse -from django.utils import timezone +from django.utils import formats, timezone from django.utils.safestring import mark_safe from django.utils.html import escape +from django.db.models import Exists, OuterRef from members.models import ( Activity, ActivityInvite, + ActivityParticipant, AdminUserInformation, Department, Person, @@ -156,11 +165,22 @@ class Meta: search_help_text = mark_safe( "Du kan søge på forening, afdeling, aktivitet eller person.
Vandret dato-filter er for aktivitetens startdato." ) + + actions = ["export_csv_invitation_info", "extend_invitations"] + form = ActivityInviteAdminForm # Only show invitation to own activities def get_queryset(self, request): - qs = super(ActivityInviteAdmin, self).get_queryset(request) + queryset = super().get_queryset(request) + qs = queryset.annotate( + is_participating=Exists( + ActivityParticipant.objects.filter( + person=OuterRef("person"), activity=OuterRef("activity") + ) + ) + ) + if request.user.is_superuser or request.user.has_perm( "members.view_all_departments" ): @@ -206,11 +226,13 @@ def person_age_years(self, item): return item.person.age_years() person_age_years.short_description = "Alder" + person_age_years.admin_order_field = "person__birthday" def person_zipcode(self, item): return item.person.zipcode person_zipcode.short_description = "Postnummer" + person_zipcode.admin_order_field = "person__zipcode" def activity_department_union_link(self, item): url = reverse( @@ -254,9 +276,124 @@ def person_link(self, item): person_link.admin_order_field = "person__name" def participating(self, item): - return item.person.activityparticipant_set.filter( - activity=item.activity - ).exists() + return item.is_participating participating.short_description = "Deltager" participating.boolean = True + participating.admin_order_field = "is_participating" + + def export_csv_invitation_info(self, request, queryset): + result_string = """"Forening"; "Afdeling"; "Aktivitet"; "Deltager";\ + "Deltager-email"; "Familie-email"; "Pris"; "Pris note"; "Ekstra email info" ;\ + "Deltager i aktiviteten"; "Invitationsdato"; "Udløbsdato"; "Afslåetdato"\n""" + + for invitation in queryset: + participate = ( + "Ja" + if invitation.person.activityparticipant_set.filter( + activity=invitation.activity + ).exists() + else "Nej" + ) + expire_date = ( + "" + if invitation.expire_dtm is None + else invitation.expire_dtm.strftime("%Y-%m-%d") + ) + rejected_date = ( + "" + if invitation.rejected_at is None + else invitation.rejected_at.strftime("%Y-%m-%d") + ) + + result_string += ( + f"{invitation.activity.department.union.name};" + f"{invitation.activity.department.name};" + f"{invitation.activity.name};" + f"{invitation.person.name};" + f"{invitation.person.email};" + f"{invitation.person.family.email};" + f"{str(invitation.price_in_dkk)};" + '"' + invitation.price_note.replace('"', '""') + '";' + '"' + invitation.extra_email_info.replace('"', '""') + '";' + f"{participate};" + f'{invitation.invite_dtm.strftime("%Y-%m-%d")};' + f"{expire_date};" + f"{rejected_date}\n" + ) + response = HttpResponse( + f'{codecs.BOM_UTF8.decode("utf-8")}{result_string}', + content_type="text/csv; charset=utf-8", + ) + response["Content-Disposition"] = ( + 'attachment; filename="invitationsoversigt.csv"' + ) + return response + + export_csv_invitation_info.short_description = "Exporter Invitationsinformationer" + + def extend_invitations(modelAdmin, request, queryset): + class ExtendInvitationsForm(forms.Form): + expires = forms.DateField( + label="Udløber", + widget=AdminDateWidget(), + initial=timezone.now() + timedelta(days=14), + ) + + invitations = queryset + + context = admin.site.each_context(request) + context["invitations"] = invitations + context["queryset"] = queryset + + expires = timezone.now() + timedelta(days=14) + context["expires"] = expires + + if request.method == "POST" and "expires" in request.POST: + extend_invitations_form = ExtendInvitationsForm(request.POST) + context["extend_invitations_form"] = extend_invitations_form + + if extend_invitations_form.is_valid(): + expires = extend_invitations_form.cleaned_data["expires"] + + if expires < timezone.now().date(): + messages.error( + request, + "Fejl - den angivne udløbsdato er før dags dato.", + ) + return + + updated_invitations = 0 + skipped_invitations = 0 + try: + with transaction.atomic(): + for invitation in invitations: + if invitation.expire_dtm > expires: + skipped_invitations += 1 + continue + + invitation.expire_dtm = expires + invitation.save() + updated_invitations += 1 + except Exception as E: + messages.error( + request, + f"Fejl - ingen invitationer blev forlænget! Følgende fejl opstod: {E=}", + ) + return + + status_text = f"{updated_invitations} invitationer blev forlænget til {formats.date_format(expires, 'DATE_FORMAT')}." + if skipped_invitations > 0: + status_text += f"
{skipped_invitations} invitationer blev sprunget over, da de allerede havde en senere udløbsdato." + messages.success( + request, + mark_safe(status_text), + ) + + return + else: + context["extend_invitations_form"] = ExtendInvitationsForm() + + return render(request, "admin/extend_invitations.html", context) + + extend_invitations.short_description = "Forlæng invitationer" diff --git a/members/admin/activityparticipant_admin.py b/members/admin/activityparticipant_admin.py index 7fd0dea4..b6b1e7f7 100644 --- a/members/admin/activityparticipant_admin.py +++ b/members/admin/activityparticipant_admin.py @@ -182,18 +182,18 @@ def queryset(self, request, queryset): class ActivityParticipantAdmin(admin.ModelAdmin): list_display = [ - "activity_department_link", "activity_link", "added_at", "activity_person_link", "activity_person_gender", "person_age_years", - "activity_family_email_link", - "person_zipcode", "photo_permission", "note", "activity_payment_info_html", + "activity_family_email_link", + "person_zipcode", "activity_activitytype", + "activity_department_link", ] list_filter = ( diff --git a/members/admin/address_admin.py b/members/admin/address_admin.py index 2e3f4830..99cc4599 100644 --- a/members/admin/address_admin.py +++ b/members/admin/address_admin.py @@ -1,6 +1,105 @@ from django.contrib import admin from members.models import Address +from members.models import ( + Union, + Department, + Activity, +) + + +class AddressUnionInline(admin.TabularInline): + # Tabular Inline list of Unions using this address object. Read Only + model = Union + + class Media: + css = {"all": ("members/css/custom_admin.css",)} # Include extra css + + classes = ["hideheader"] + extra = 0 + fields = ("name",) + readonly_fields = fields + can_delete = False + + def get_queryset(self, request): + return Union.objects.all().order_by("name") + + def has_add_permission(self, request, obj=None): + return False + + +class AddressDepartmentInline(admin.TabularInline): + # Tabular Inline list of Departments using this address object. Read Only + model = Department + + class Media: + css = {"all": ("members/css/custom_admin.css",)} # Include extra css + + classes = ["hideheader"] + extra = 0 + fields = ("name",) + readonly_fields = fields + can_delete = False + + def get_queryset(self, request): + return Department.objects.all().order_by("name") + + def has_add_permission(self, request, obj=None): + return False + + +class AddressActivityInline(admin.TabularInline): + # Tabular Inline list of Activities using this address object. Read Only + model = Activity + + class Media: + css = {"all": ("members/css/custom_admin.css",)} # Include extra css + + classes = ["hideheader"] + extra = 0 + fields = ( + "name", + "start_date", + "end_date", + "department", + ) + readonly_fields = fields + can_delete = False + + def get_queryset(self, request): + return Activity.objects.all().order_by("name") + + def has_add_permission(self, request, obj=None): + return False + + +class AddressRegionListFilter(admin.SimpleListFilter): + # List filter on region values + title = "Regioner" + parameter_name = "region" + + def lookups(self, request, model_admin): + regionList = [("none", "(ingen region)")] + lastRegion = "" + for aRegion in Address.objects.all().order_by("region"): + if aRegion.region != lastRegion: + lastRegion = aRegion.region + regionList += ( + ( + str(aRegion.region), + str(aRegion.region), + ), + ) + return regionList + + def queryset(self, request, queryset): + region_id = request.GET.get(self.parameter_name, None) + if region_id == "none": + return queryset.filter(region="") + if region_id: + return queryset.filter(region=region_id) + return queryset + class AddressAdmin(admin.ModelAdmin): readonly_fields = ( @@ -8,6 +107,44 @@ class AddressAdmin(admin.ModelAdmin): "created_by", ) + search_fields = ( + "id", + "streetname", + "housenumber", + "floor", + "door", + "placename", + "zipcode", + "city", + "region", + "descriptiontext", + "dawa_id", + ) + + list_display = ( + "id", + "streetname", + "housenumber", + "floor", + "door", + "placename", + "zipcode", + "city", + "region", + "descriptiontext", + "dawa_id", + "dawa_category", + ) + + class Media: + # Remove title for each record + # see : https://stackoverflow.com/questions/41376406/remove-title-from-tabularinline-in-admin + css = {"all": ("members/css/custom_admin.css",)} # Include extra css + + inlines = [AddressUnionInline, AddressDepartmentInline, AddressActivityInline] + + list_filter = (AddressRegionListFilter,) + def get_queryset(self, request): return Address.get_user_addresses(request.user) @@ -16,21 +153,44 @@ def get_queryset(self, request): "Adresse", { "fields": ( - "streetname", - "housenumber", - "floor", - "door", - "zipcode", - "city", - "municipality", - "region", + ( + "streetname", + "housenumber", + ), + ( + "floor", + "door", + ), + "placename", + ( + "zipcode", + "city", + ), + ( + "municipality", + "region", + ), + "descriptiontext", ) }, ), ( "Dawa info", { - "fields": ("dawa_id", "dawa_overwrite", "longitude", "latitude"), + "description": """ +

ID, kategori, længde- og breddegrad fra DAWA.

+

Du kan vælge at sætte egne værdier for længde- og breddegrad.

""", + "fields": ( + ( + "dawa_id", + "dawa_category", + ), + "dawa_overwrite", + ( + "longitude", + "latitude", + ), + ), "classes": ("collapse",), }, ), diff --git a/members/admin/admin_actions.py b/members/admin/admin_actions.py index 8dbd2440..8826400b 100644 --- a/members/admin/admin_actions.py +++ b/members/admin/admin_actions.py @@ -1,6 +1,5 @@ import codecs from datetime import timedelta -from dateutil.relativedelta import relativedelta from django import forms from django.contrib import admin from django.contrib import messages @@ -15,6 +14,8 @@ from django.shortcuts import render import members.models.emailtemplate import members.models.waitinglist +from members.utils.age_check import check_is_person_too_young +from members.utils.age_check import check_is_person_too_old from members.models import ( Activity, @@ -36,10 +37,12 @@ def invite_many_to_activity_action(modelAdmin, request, queryset): if request.user.is_superuser or request.user.has_perm( "members.view_all_departments" ): - department_list_query = Department.objects.all().order_by("name") + department_list_query = Department.objects.filter( + closed_dtm__isnull=True + ).order_by("name") else: department_list_query = Department.objects.filter( - adminuserinformation__user=request.user + adminuserinformation__user=request.user, closed_dtm__isnull=True ).order_by("name") department_list = [("-", "-")] for department in department_list_query: @@ -205,25 +208,13 @@ class MassInvitationForm(forms.Form): ) # Check for age constraint: too young ? - # Since it's a "negative-list" of people who won't be invited, we use AND operator - # to add to list if person is too young at activity start AND today - elif ( - current_person.birthday - > activity.start_date - - relativedelta(years=activity.min_age) - ) and ( - current_person.birthday - > timezone.now().date() - - relativedelta(years=activity.min_age) + elif check_is_person_too_young( + activity, current_person ): persons_too_young.append(current_person.name) # Check for age constraint: too old ? - elif ( - current_person.birthday - < activity.start_date - - relativedelta( - years=activity.max_age + 1, days=-1 - ) + elif check_is_person_too_old( + activity, current_person ): persons_too_old.append(current_person.name) # Otherwise - person can be invited diff --git a/members/admin/department_admin.py b/members/admin/department_admin.py index 04fe1973..8502bf71 100644 --- a/members/admin/department_admin.py +++ b/members/admin/department_admin.py @@ -1,9 +1,87 @@ +import codecs from django.contrib import admin from django.db.models.functions import Upper from django.urls import reverse from django.utils.safestring import mark_safe -from members.models import Union, Address, Person +from members.models import ( + Union, + Address, + Person, + Activity, + AdminUserInformation, +) from django.utils.html import escape +from django.http import HttpResponse +from django.db.models import Count + + +class AdminUserDepartmentInline(admin.TabularInline): + model = AdminUserInformation.departments.through + + class Media: + css = {"all": ("members/css/custom_admin.css",)} # Include extra css + + classes = ["hideheader"] + + extra = 0 + verbose_name = "Admin Bruger" + verbose_name_plural = "Admin Brugere" + + fields = ( + "user_username", + "user_first_name", + "user_last_name", + "user_email", + "user_last_login", + ) + readonly_fields = ( + "user_username", + "user_first_name", + "user_last_name", + "user_email", + "user_last_login", + ) + + def get_queryset(self, request): + qs = super().get_queryset(request) + # Filter out inactive users and non-staff users + return qs.filter( + adminuserinformation__user__is_active=True, + adminuserinformation__user__is_staff=True, + ).select_related("adminuserinformation__user") + + def user_username(self, instance): + return instance.adminuserinformation.user.username + + user_username.short_description = "Brugernavn" + + def user_first_name(self, instance): + return instance.adminuserinformation.user.first_name + + user_first_name.short_description = "Fornavn" + + def user_last_name(self, instance): + return instance.adminuserinformation.user.last_name + + user_last_name.short_description = "Efternavn" + + def user_email(self, instance): + return instance.adminuserinformation.user.email + + user_email.short_description = "Email" + + def user_last_login(self, instance): + return instance.adminuserinformation.user.last_login.strftime( + "%Y-%m-%d %H:%M:%S" + ) + + user_last_login.short_description = "Sidste login" + + def has_add_permission(self, request, obj): + return False + + def has_delete_permission(self, request, obj=None): + return False class UnionDepartmentFilter(admin.SimpleListFilter): @@ -18,16 +96,17 @@ def queryset(self, request, queryset): class DepartmentAdmin(admin.ModelAdmin): + inlines = [AdminUserDepartmentInline] list_display = ( - "id", - "department_union_link", "department_link", "address", "isVisible", "isOpening", + "has_waiting_list", "created", "closed_dtm", "waitinglist_count_link", + "department_union_link", ) list_filter = ( "address__region", @@ -36,7 +115,9 @@ class DepartmentAdmin(admin.ModelAdmin): "isOpening", "created", "closed_dtm", + "has_waiting_list", ) + autocomplete_fields = ("union",) raw_id_fields = ("union",) search_fields = ( "name", @@ -47,8 +128,13 @@ class DepartmentAdmin(admin.ModelAdmin): "address__zipcode", "address__city", ) + ordering = ["name"] filter_horizontal = ["department_leaders"] + actions = [ + "export_department_info_csv", + ] + # Solution found on https://stackoverflow.com/questions/57056994/django-model-form-with-only-view-permission-puts-all-fields-on-exclude # formfield_for_foreignkey described in documentation here: https://docs.djangoproject.com/en/4.2/ref/contrib/admin/#django.contrib.admin.ModelAdmin.formfield_for_foreignkey def formfield_for_foreignkey(self, db_field, request, **kwargs): @@ -64,7 +150,9 @@ def formfield_for_manytomany(self, db_field, request, **kwargs): return super().formfield_for_manytomany(db_field, request, **kwargs) def get_queryset(self, request): - qs = super(DepartmentAdmin, self).get_queryset(request) + queryset = super().get_queryset(request) + qs = queryset.annotate(waitinglist_count=Count("waitinglist")) + if request.user.is_superuser or request.user.has_perm( "members.view_all_departments" ): @@ -97,7 +185,7 @@ def get_queryset(self, request): ( "Yderlige data", { - "fields": ("created", "closed_dtm"), + "fields": ("has_waiting_list", "created", "closed_dtm"), "description": "

Venteliste betyder at børn har mulighed for at skrive sig på ventelisten (tilkendegive interesse for denne afdeling). Den skal typisk altid være krydset af.

", "classes": ("collapse",), }, @@ -125,8 +213,81 @@ def waitinglist_count_link(self, item): link = f""" - {item.waitinglist_set.count()} + {item.waitinglist_count} """ return mark_safe(link) waitinglist_count_link.short_description = "Venteliste" + waitinglist_count_link.admin_order_field = "waitinglist_count" + + def export_department_info_csv(self, request, queryset): + result_string = """"Forening"; "Afdeling"; "Afdeling-Startdato"; "Afdeling-lukkedato";\ + "Kaptajn"; "Kaptajn-email"; "Kaptajn-telefon";\ + "Adresse"; "Post#"; "By"; "Region";\ + "Dato-sidste-forløb";\ + "Dato-sidste-arrangement";\ + "Dato-sidste-foreningsmedlemskab";\ + "Dato-sidste-støttemedlemskab"\n""" + + # There can be multiple departmentleaders (or even none) + + for d in queryset.order_by("name"): + info1 = d.union.name + ";" + info1 += d.name + ";" + if d.created is not None: + info1 += d.created.strftime("%Y-%m-%d") + info1 += ";" + if d.closed_dtm is not None: + info1 += d.closed_dtm.strftime("%Y-%m-%d") + info1 += ";" + info2 = d.address.streetname + if d.address.housenumber != "": + info2 += " " + d.address.housenumber + if d.address.floor != "" or d.address.door != "": + info2 += ", " + if d.address.floor != "": + info2 += d.address.floor + "." + if d.address.door != "": + info2 += d.address.door + info2 += ";" + info2 += d.address.zipcode + ";" + info2 += d.address.city + ";" + info2 += d.address.region + ";" + + info2 += GetLastDate(d, "FORLØB") + ";" + info2 += GetLastDate(d, "ARRANGEMENT") + ";" + info2 += GetLastDate(d, "FORENINGSMEDLEMSKAB") + ";" + info2 += GetLastDate(d, "STØTTEMEDLEMSKAB") + ";" + + leaders = d.department_leaders.all().order_by("name") + + if leaders.count() == 0: + result_string += info1 + ";;;" + info2 + "\n" + else: + for leader in leaders: + result_string += info1 + result_string += leader.name + ";" + result_string += leader.email + ";" + result_string += leader.phone + ";" + result_string += info2 + "\n" + + response = HttpResponse( + f'{codecs.BOM_UTF8.decode("utf-8")}{result_string}', + content_type="text/csv; charset=utf-8", + ) + response["Content-Disposition"] = 'attachment; filename="afdelingsinfo.csv"' + return response + + export_department_info_csv.short_description = "Exporter Afdelingsinfo (CSV)" + + +def GetLastDate(department_id, activity_type): + last_activity = ( + Activity.objects.all() + .filter(department=department_id, activitytype=activity_type) + .order_by("-start_date") + .first() + ) + return ( + "" if last_activity is None else last_activity.start_date.strftime("%Y-%m-%d") + ) diff --git a/members/admin/emailitem_admin.py b/members/admin/emailitem_admin.py new file mode 100644 index 00000000..477f2d0b --- /dev/null +++ b/members/admin/emailitem_admin.py @@ -0,0 +1,210 @@ +from typing import Any +from django.contrib import admin +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +# from members.models import EmailItem + +from members.models import ( + EmailItem, + Activity, + Department, +) + + +class activityFilter(admin.SimpleListFilter): + title = _("Aktivitet") + parameter_name = "activity" + + def lookups(self, request: Any, model_admin: Any) -> list[tuple[Any, str]]: + + queryset = EmailItem.objects + filtervalue = None + queryset, filtervalue = getRequestDateFilter( + request, "created_dtm__year", queryset, filtervalue + ) + queryset, filtervalue = getRequestDateFilter( + request, "created_dtm__month", queryset, filtervalue + ) + queryset, filtervalue = getRequestDateFilter( + request, "created_dtm__day", queryset, filtervalue + ) + if filtervalue is not None: + activities = ( + queryset.filter(activity__isnull=False) + .values_list("activity", flat=True) + .order_by("id") + .distinct() + ) + else: + activities = ( + EmailItem.objects.filter(activity__isnull=False) + .values_list("activity", flat=True) + .order_by("id") + .distinct() + ) + + if filtervalue is not None: + self.title = f"Aktivitet (mail {filtervalue})" + + activityList = [("none", "(Ingen aktivitet)")] + for activity in Activity.objects.filter(id__in=activities).order_by("name"): + activityList.append((str(activity.id), str(activity.name))) + return activityList + + def queryset(self, request, queryset): + if self.value() == "none": + return queryset.filter(activity__isnull=True).distinct() + if self.value(): + return queryset.filter(activity=self.value()).order_by("activity__name") + return queryset.order_by("activity__name") + + +class departmentFilter(admin.SimpleListFilter): + title = _("Afdeling") + parameter_name = "department__calculated" + + def lookups(self, request: Any, model_admin: Any) -> list[tuple[Any, str]]: + queryset = EmailItem.objects + filtervalue = None + queryset, filtervalue = getRequestDateFilter( + request, "created_dtm__year", queryset, filtervalue + ) + queryset, filtervalue = getRequestDateFilter( + request, "created_dtm__month", queryset, filtervalue + ) + queryset, filtervalue = getRequestDateFilter( + request, "created_dtm__day", queryset, filtervalue + ) + if filtervalue is not None: + departments = ( + queryset.filter(department__isnull=False) + .values_list("department__id", flat=True) + .order_by("department__name") + .distinct() + ) + + departments = departments.union( + queryset.filter(activity__department__isnull=False) + .values_list("activity__department__id", flat=True) + .order_by("activity__department__name") + .distinct() + ) + + else: + departments = EmailItem.objects.values_list( + "department", flat=True + ).distinct() + departments = departments.union( + EmailItem.objects.values_list( + "activity__department", flat=True + ).distinct() + ) + if filtervalue is not None: + self.title = f"Afdeling (mail {filtervalue})" + + return [ + (str(department.id), str(department.name)) + for department in Department.objects.filter(id__in=departments).order_by( + "name" + ) + ] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(department=self.value()) | queryset.filter( + activity__department=self.value() + ) + + return queryset.order_by("department") + + +class EmailItemAdmin(admin.ModelAdmin): + list_display = [ + "created_dtm", + "receiver", + "departmentName", + "activityName", + "subject", + ] + list_filter = [ + departmentFilter, + activityFilter, + ] + + date_hierarchy = "created_dtm" + search_fields = ("person__name", "family__email", "activity__name", "subject") + search_help_text = mark_safe( + "Du kan søge på personnavn, familie-email, afdelingsnavn, aktivitetsnavn eller email emne.
Vandret dato-filter er for hvornår emailen er oprettet" + ) + readonly_fields = ("created_dtm", "send_error", "sent_dtm") + + def get_queryset(self, request): + qs = super(EmailItemAdmin, self).get_queryset(request) + if request.user.is_superuser or request.user.has_perm( + "members.view_all_departments" + ): + return qs + departments = Department.objects.filter(adminuserinformation__user=request.user) + return qs.filter(department__in=departments) | qs.filter( + activity__department__in=departments + ) + + fieldsets = [ + ( + "Modtager information", + { + "description": "Information om modtager (navn, familie, email)", + "fields": ( + "person", + "receiver", + "family", + ), + }, + ), + ( + "Email information", + { + "description": "Indhold i email", + "fields": ( + "created_dtm", + "subject", + "body_text", + "body_html", + ), + }, + ), + ( + "Yderlige data", + { + "description": "Diverse information om denne email", + "fields": ( + "template", + "bounce_token", + "activity", + "department", + "sent_dtm", + "send_error", + ), + "classes": ("collapse",), + }, + ), + ] + + +def getRequestDateFilter(request, date_type, queryset, filtervalue): + if date_type in request.GET: + date_part = request.GET[date_type] + if date_type != "created_dtm__year": + if len(date_part) == 1: + date_part = f"0{date_part}" + + if filtervalue is None: + filtervalue = date_part + else: + filtervalue += "-" + date_part + + kwargs = {f"{date_type}": date_part} + + return [queryset.filter(**kwargs), filtervalue] + return [queryset, filtervalue] diff --git a/members/admin/inlines.py b/members/admin/inlines.py index 84fd9f61..65497710 100644 --- a/members/admin/inlines.py +++ b/members/admin/inlines.py @@ -81,8 +81,13 @@ class Media: css = {"all": ("members/css/custom_admin.css",)} # Include extra css model = EmailItem - classes = ["hideheader"] - fields = ["sent_ymdhm_html", "reciever", "subject_and_email_html"] + classes = ["hideheader", "collapse"] + fields = [ + "created_ymdhm", + "sent_ymdhm_text", + "receiver", + "email_link", + ] can_delete = False readonly_fields = fields diff --git a/members/admin/municipality_admin.py b/members/admin/municipality_admin.py new file mode 100644 index 00000000..64c9d20c --- /dev/null +++ b/members/admin/municipality_admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + + +class MunicipalityAdmin(admin.ModelAdmin): + list_display = ("name", "address", "zipcode", "city", "dawa_id") diff --git a/members/admin/person_admin.py b/members/admin/person_admin.py index 7054e3f5..02b2af2b 100644 --- a/members/admin/person_admin.py +++ b/members/admin/person_admin.py @@ -24,6 +24,7 @@ PaymentInline, VolunteerInline, WaitingListInline, + EmailItemInline, ) from members.admin.admin_actions import AdminActions @@ -64,6 +65,7 @@ class PersonAdmin(admin.ModelAdmin): VolunteerInline, ActivityInviteInline, WaitingListInline, + EmailItemInline, ] def family_url(self, item): @@ -166,7 +168,7 @@ def export_emaillist(self, request, queryset): export_emaillist.short_description = "Exporter e-mail liste" def export_csv(self, request, queryset): - result_string = "Navn;Alder;Opskrevet;Tlf (barn);Email (barn);" + result_string = "Navn;Alder;Køn;Opskrevet;Tlf (barn);Email (barn);" result_string += "Tlf (forælder);Email (familie);Postnummer;Noter\n" for person in queryset: parent = person.family.get_first_parent() @@ -188,6 +190,8 @@ def export_csv(self, request, queryset): + ";" + str(person.age_years()) + ";" + + str(person.gender_text()) + + ";" + str(person.added_at.strftime("%Y-%m-%d %H:%M")) + ";" + person.phone diff --git a/members/admin/union_admin.py b/members/admin/union_admin.py index edda6fe7..35f27ef3 100644 --- a/members/admin/union_admin.py +++ b/members/admin/union_admin.py @@ -6,19 +6,84 @@ from django.utils.safestring import mark_safe from django.utils.html import escape -from members.models import ( - Address, - Person, - Department, -) +from members.models import Address, Person, Department, AdminUserInformation + + +class AdminUserUnionInline(admin.TabularInline): + model = AdminUserInformation.unions.through + + class Media: + css = {"all": ("members/css/custom_admin.css",)} # Include extra css + + classes = ["hideheader"] + + extra = 0 + verbose_name = "Admin Bruger" + verbose_name_plural = "Admin Brugere" + + fields = ( + "user_username", + "user_first_name", + "user_last_name", + "user_email", + "user_last_login", + ) + readonly_fields = ( + "user_username", + "user_first_name", + "user_last_name", + "user_email", + "user_last_login", + ) + + def get_queryset(self, request): + qs = super().get_queryset(request) + # Filter out inactive users and non-staff users + return qs.filter( + adminuserinformation__user__is_active=True, + adminuserinformation__user__is_staff=True, + ).select_related("adminuserinformation__user") + + def user_username(self, instance): + return instance.adminuserinformation.user.username + + user_username.short_description = "Brugernavn" + + def user_first_name(self, instance): + return instance.adminuserinformation.user.first_name + + user_first_name.short_description = "Fornavn" + + def user_last_name(self, instance): + return instance.adminuserinformation.user.last_name + + user_last_name.short_description = "Efternavn" + + def user_email(self, instance): + return instance.adminuserinformation.user.email + + user_email.short_description = "Email" + + def user_last_login(self, instance): + return instance.adminuserinformation.user.last_login.strftime( + "%Y-%m-%d %H:%M:%S" + ) + + user_last_login.short_description = "Sidste login" + + def has_add_permission(self, request, obj): + return False + + def has_delete_permission(self, request, obj=None): + return False class UnionAdmin(admin.ModelAdmin): + inlines = [AdminUserUnionInline] list_display = ( - "id", "union_link", "address", - "union_email", + "email", "founded_at", "closed_at", "waitinglist_count_link", @@ -28,11 +93,83 @@ class UnionAdmin(admin.ModelAdmin): "founded_at", "closed_at", ) + search_fields = ("name",) filter_horizontal = ["board_members"] raw_id_fields = ("chairman", "second_chair", "cashier", "secretary") actions = ["export_csv_union_info"] + def get_fieldsets(self, request, obj=None): + # 20241113: https://stackoverflow.com/questions/16102222/djangoremove-superuser-checkbox-from-django-admin-panel-when-login-staff-users + + if not obj: + return self.add_fieldsets + + info_fields = ( + "bank_main_org", + "bank_account", + "statues", + "founded_at", + "closed_at", + "gl_account", + ) + + if not request.user.has_perm("members.showledgeraccount"): + info_fields = ( + "bank_main_org", + "bank_account", + "statues", + "founded_at", + "closed_at", + ) + + return [ + ( + "Navn og Adresse", + { + "fields": ("name", "email", "address"), + "description": "

Udfyld navnet på foreningen (f.eks København, \ + vestjylland) og adressen

", + }, + ), + ( + "Bestyrelsen nye felter", + { + "fields": ( + "chairman", + "second_chair", + "cashier", + "secretary", + "board_members", + ) + }, + ), + ( + "Bestyrelsen gamle felter", + { + "fields": ( + "chairman_old", + "chairman_email_old", + "second_chair_old", + "second_chair_email_old", + "cashier_old", + "cashier_email_old", + "secretary_old", + "secretary_email_old", + "board_members_old", + ) + }, + ), + ( + "Info", + { + "fields": info_fields, + "description": "Indsæt et link til jeres vedtægter, hvornår I er stiftet (har holdt stiftende \ + generalforsamling) og jeres bankkonto hvis I har sådan en til foreningen.", + }, + ), + ] + # Solution found on https://stackoverflow.com/questions/57056994/django-model-form-with-only-view-permission-puts-all-fields-on-exclude # formfield_for_foreignkey described in documentation here: https://docs.djangoproject.com/en/4.2/ref/contrib/admin/#django.contrib.admin.ModelAdmin.formfield_for_foreignkey def formfield_for_foreignkey(self, db_field, request, **kwargs): @@ -55,59 +192,6 @@ def get_queryset(self, request): return qs return qs.filter(adminuserinformation__user=request.user) - fieldsets = [ - ( - "Navn og Adresse", - { - "fields": ("name", "union_email", "address"), - "description": "

Udfyld navnet på foreningen (f.eks København, \ - vestjylland) og adressen

", - }, - ), - ( - "Bestyrelsen nye felter", - { - "fields": ( - "chairman", - "second_chair", - "cashier", - "secretary", - "board_members", - ) - }, - ), - ( - "Bestyrelsen gamle felter", - { - "fields": ( - "chairman_old", - "chairman_email_old", - "second_chair_old", - "second_chair_email_old", - "cashier_old", - "cashier_email_old", - "secretary_old", - "secretary_email_old", - "board_members_old", - ) - }, - ), - ( - "Info", - { - "fields": ( - "bank_main_org", - "bank_account", - "statues", - "founded_at", - "closed_at", - ), - "description": "Indsæt et link til jeres vedtægter, hvornår I er stiftet (har holdt stiftende \ - generalforsamling) og jeres bankkonto hvis I har sådan en til foreningen.", - }, - ), - ] - def union_link(self, item): url = reverse("admin:members_union_change", args=[item.id]) link = '%s' % (url, escape(item.name)) diff --git a/members/admin/waitinglist_admin.py b/members/admin/waitinglist_admin.py index 648f4ba3..5ae5af31 100644 --- a/members/admin/waitinglist_admin.py +++ b/members/admin/waitinglist_admin.py @@ -79,14 +79,16 @@ def get_form(self, request, obj=None, change=False, **kwargs): return form list_display = ( - "union_link", "department_link", "person_link", "person_age_years", "person_gender_text", + "zipcode", + "municipality", "user_waiting_list_number", "user_created", "user_added_waiting_list", + "union_link", ) list_filter = ( @@ -99,9 +101,11 @@ def get_form(self, request, obj=None, change=False, **kwargs): "department__name", "department__union__name", "person__name", + "person__zipcode", + "person__municipality", ] search_help_text = mark_safe( - """Du kan søge på forening, afdeling eller person.
+ """Du kan søge på forening (navn), afdeling (navn) eller person (navn, postnummer eller kommune).
'Nummer på venteliste' er relateret til personernes oprettelsestidspunkt""" ) @@ -278,7 +282,7 @@ def get_queryset(self, request): ).distinct() def union_link(self, item): - url = reverse("admin:members_union_change", args=[item.id]) + url = reverse("admin:members_union_change", args=[item.department.union_id]) link = '%s' % (url, escape(item.department.union.name)) return mark_safe(link) @@ -328,5 +332,17 @@ def user_added_waiting_list(self, item): def user_waiting_list_number(self, item): return item.number_on_waiting_list() - user_waiting_list_number.short_description = "Nummer på venteliste" + user_waiting_list_number.short_description = "Ventelistenummer" user_waiting_list_number.admin_order_field = "on_waiting_list_since" + + def zipcode(self, item): + return item.person.zipcode + + zipcode.short_description = "Post nr" + zipcode.admin_order_field = "person__zipcode" + + def municipality(self, item): + return item.person.municipality + + municipality.short_description = "Kommune" + municipality.admin_order_field = "person__municipality" diff --git a/members/forms/activity_signup_form.py b/members/forms/activity_signup_form.py index 3ce9e1ec..ed96a8dc 100644 --- a/members/forms/activity_signup_form.py +++ b/members/forms/activity_signup_form.py @@ -52,7 +52,9 @@ def __init__(self, *args, **kwargs): '

{% if activity.will_reserve %} Denne betaling vil kun blive reserveret på dit kort. Vi hæver den først endeligt d. 1/1 det år aktiviteten starter for at sikre, at {{ person.name }} er meldt korrekt ind i foreningen i kalenderåret.{% endif %}

' ), Submit( - "submit", "Tilmeld og betal", css_class="button-success" + "submit", + "Tilmeld{% if price > 0 %} og betal{% endif %}", + css_class="button-success", ), HTML("Tilbage"), ), diff --git a/members/management/commands/activity_address_migration.py b/members/management/commands/activity_address_migration.py new file mode 100644 index 00000000..c2268ade --- /dev/null +++ b/members/management/commands/activity_address_migration.py @@ -0,0 +1,128 @@ +import requests + +from django.core.management.base import BaseCommand +from django.db.models import Q +from members.models.activity import Activity +from members.models.address import Address + + +class Command(BaseCommand): + help = "Update all activities, to use Address object" + + def default_value(self, value, defaultvalue): + if value is None: + return defaultvalue + else: + return value + + def handle(self, *args, **options): + for activity in Activity.objects.filter( + Q(address_id=None) | Q(address_id=1) + ).order_by("-id"): + _create_new_address = True + _streetname = self.default_value(activity.streetname, "") + _housenumber = self.default_value(activity.housenumber, "") + _floor = self.default_value(activity.floor, "") + _door = self.default_value(activity.door, "") + _descriptiontext = self.default_value(activity.placename, "") + _zipcode = self.default_value(activity.zipcode, "") + _city = self.default_value(activity.city, "") + print( + f"Activity:[{activity.pk}]:[{activity.name}]. Place/descr:[{activity.placename}] descrtxt:[{_descriptiontext}]\r\n" + "" + ) + # housenumber, floor and door might be too long (fields in address object is smaller) + if len(_housenumber) > 5: + _descriptiontext = f"{_descriptiontext}.*. {_housenumber}" + _housenumber = "" + + if len(_floor) > 10: + _descriptiontext = f"{_descriptiontext}.**. {_floor}" + _floor = "" + + if len(_door) > 5: + _descriptiontext = f"{_descriptiontext}.***. {_door}" + _door = "" + + # Check for existing address object + address = ( + Address.objects.filter( + streetname=_streetname, + housenumber=_housenumber, + floor=_floor, + door=_door, + zipcode=_zipcode, + city=_city, + ) + .order_by("-id") + .first() + ) + + # If address object found, then use this object in address_id for the activity, + print( + f" Vej:[{_streetname}] #:[{_housenumber}] Floor:[{_floor}] Dør:[{_door}]. ZIP:[{_zipcode}] By:[{_city}]" + ) + + if address is None: + # Exact address does not exist, but there might be a record with + # same DAWA id (e.g. if you searched for "Frode Jakobsens Pl." + # it's mapped via DAWA to "Frode Jakobsens Plads" + + print(" address is None. Checking Dawa online") + _dawa_id = "" + + # build text for DAWA request + text = f"{_streetname} {_housenumber}" + text = f"{text} {_floor}" if _floor != "" else text + text = f"{text} {_door}" if _door != "" else text + # text = f"{text}, {self.placename}" if self.placename != "" else text + text = f"{text}, {_zipcode} {_city}" + + wash_response = requests.request( + "GET", + "https://dawa.aws.dk/datavask/adresser", + params={"betegnelse": text}, + ) + _category = wash_response.json()["kategori"] + if wash_response.status_code == 200 and _category != "C": + _dawa_id = wash_response.json()["resultater"][0]["adresse"]["id"] + + print(f" Checking for existing address w Dawa [{_dawa_id}]") + + address_check = ( + Address.objects.filter( + dawa_id=_dawa_id, + ) + .order_by("id") + .first() + ) + + if address_check is not None: + activity.address = address_check + _dawa_id = address_check.dawa_id + _create_new_address = False + print( + f" using address_check. dawa=[{address_check.dawa_id}] pk=[{address_check.pk}]" + ) + print(f" _dawa_id = [{_dawa_id}]") + if _create_new_address: + print(" Creating new address") + address_new = Address.objects.create( + streetname=_streetname, + housenumber=_housenumber, + floor=_floor, + door=_door, + descriptiontext=_descriptiontext, + zipcode=_zipcode, + city=_city, + ) + print( + f" Created new address to match DAWA [{address_new.dawa_id}]" + ) + activity.address = address_new + + else: + print(" adress exists already !") + _create_new_address = True + activity.address = address + activity.save() diff --git a/members/management/commands/import_municipalities.py b/members/management/commands/import_municipalities.py new file mode 100644 index 00000000..4adf0681 --- /dev/null +++ b/members/management/commands/import_municipalities.py @@ -0,0 +1,49 @@ +import csv +from django.core.management.base import BaseCommand +from members.models import Municipality + +# run locally: +# docker compose run web ./manage.py import_municipalities members/management/commands/municipalities.csv + + +class Command(BaseCommand): + help = "Import municipalities data from a CSV file into the Municipality model" + + def add_arguments(self, parser): + parser.add_argument( + "csv_file", + type=str, + help="Path to the CSV file containing municipalities data", + ) + + def handle(self, *args, **kwargs): + csv_file_path = kwargs["csv_file"] + + try: + # Delete existing rows in Municipality model + Municipality.objects.all().delete() + self.stdout.write("Deleted all existing municipalities.") + + with open(csv_file_path, mode="r", encoding="utf-8") as file: + reader = csv.reader(file, delimiter=";") + for row in reader: + name, address, zipcode, city, dawa_id = row + Municipality.objects.create( + name=name, + address=address, + zipcode=zipcode, + city=city, + dawa_id=dawa_id, + ) + self.stdout.write(f"Added municipality: {name}") + + self.stdout.write( + self.style.SUCCESS("Successfully imported all municipalities") + ) + + except FileNotFoundError: + self.stdout.write( + self.style.ERROR( + f"File {csv_file_path} not found. Please check the file path." + ) + ) diff --git a/members/management/commands/municipalities.csv b/members/management/commands/municipalities.csv new file mode 100644 index 00000000..b72f99f8 --- /dev/null +++ b/members/management/commands/municipalities.csv @@ -0,0 +1,98 @@ +Albertslund;Nordmarks Allé;2620;Albertslund;0165 +Allerød;Bjarkesvej;3450;Allerød;0201 +Assens;Rådhus Allé 5;5610;Assens;0420 +Ballerup;Hold-an Vej 7;2750;Ballerup;0151 +Billund;Jorden Rundt 1;7200;Grindsted;0530 +Bornholm;Ullasvej 23;3700;Rønne;0400 +Brøndby;Park Allé 160;2605;Brøndby;0153 +Brønderslev;Ny Rådhusplads 1;9700;Brønderslev;0810 +Dragør;Kirkevej 7;2791;Dragør;0155 +Egedal;Dronning Dagmars Vej 200;3660;Stenløse;0240 +Esbjerg;Torvegade 74;6700;Esbjerg;0561 +Fanø;Skolevej 5-7;6720;Fanø;0563 +Favrskov;Skovvej 20;8382;Hinnerup;0710 +Faxe;Frederiksgade 9;4690;Haslev;0320 +Fredensborg;Egevangen 3 B;2980;Kokkedal;0210 +Fredericia;Gothersgade 20;7000;Fredericia;0607 +Frederiksberg;Smallegade 1;2000;Frederiksberg;0147 +Frederikshavn;Rådhus Allé 100;9900;Frederikshavn;0813 +Frederikssund;Torvet 2;3600;Frederikssund;0250 +Furesø;Rådhustorvet 2;3520;Farum;0190 +Faaborg-Midtfyn;Tinghøj Allé 2;5750;Ringe;0430 +Gentofte;Bernstorffsvej 161;2920;Charlottenlund;0157 +Gladsaxe;Rådhus Allé 7;2860;Søborg;0159 +Glostrup;Rådhusparken 2;2600;Glostrup;0161 +Greve;Rådhusholmen 10;2670;Greve;0253 +Gribskov;Rådhusvej 3;3200;Helsinge;0270 +Guldborgsund;Parkvej 37;4800;Nykøbing Falster;0376 +Haderslev;Christian X's Vej 39;6100;Haderslev;0510 +Halsnæs;Rådhuspladsen 1;3300;Frederiksværk;0260 +Hedensted;Niels Espes Vej 8;8722;Hedensted;0766 +Helsingør;Stengade 59;3000;Helsingør;0217 +Herlev;Herlev Bygade 90;2730;Herlev;0163 +Herning;Torvet;7400;Herning;0657 +Hillerød;Trollesmindealle 27;3400;Hillerød;0219 +Hjørring;Nørregade 2;9800;Hjørring;1081 +Holbæk;Kanalstræde 2;4300;Holbæk;0316 +Holstebro;Kirkestræde 11;7500;Holstebro;0661 +Horsens;Rådhustorvet 4;8700;Horsens;0615 +Hvidovre;Hvidovrevej 278;2650;Hvidovre;0167 +Høje-Taastrup;Rådhusstræde 1;2630;Taastrup;0169 +Hørsholm;Slotsmarken 13;2970;Hørsholm;0223 +Ikast-Brande;Rådhusstrædet 6;7430;Ikast;0756 +Ishøj;Ishøj Store Torv 20;2635;Ishøj;0183 +Jammerbugt;Toftevej 43;9440;Aabybro;0849 +Kalundborg;Klosterparkvej 7;4400;Kalundborg;0326 +Kerteminde;Hans Schacksvej 4;5300;Kerteminde;0440 +Kolding;Akseltorv 1;6000;Kolding;0621 +København;Rådhuset;1599;København V;0101 +Køge;Torvet 1;4600;Køge;0259 +Langeland;Fredensvej 1;5900;Rudkøbing;0482 +Lejre;Møllebjergvej 4;4330;Hvalsø;0350 +Lemvig;Rådhusgade 2;7620;Lemvig;0665 +Lolland;Jernbanegade 7;4930;Maribo;0360 +Lyngby-Taarbæk;Lyngby Torv;2800;Kongens Lyngby;0173 +Læsø;Doktorvejen 2;9940;Læsø;0825 +Mariagerfjord;Nordre Kajgade 1;9500;Hobro;0846 +Middelfart;Nytorv 9;5500;Middelfart;0410 +Morsø;Jernbanevej 7;7900;Nykøbing Mors;0773 +Norddjurs;Torvet 3;8500;Grenaa;0707 +Nordfyns;Østergade 23;5400;Bogense;0480 +Nyborg;Torvet 1;5800;Nyborg;0450 +Næstved;Rådmandshaven 20;4700;Næstved;0370 +Odder;Rådhusgade 3;8300;Odder;0727 +Odense;Flakhaven 2;5000;Odense C;0461 +Odsherred;Nyvej 22;4573;Højby;0306 +Randers;Laksetorvet;8900;Randers C;0730 +Rebild;Hobrovej 110;9530;Støvring;0840 +Ringkøbing-Skjern;Ved Fjorden 6;6950;Ringkøbing;0760 +Ringsted;Sct. Bendtsgade 1;4100;Ringsted;0329 +Roskilde;Rådhusbuen 1;4000;Roskilde;0265 +Rudersdal;Øverødvej 2;2840;Holte;0230 +Rødovre;Rødovre Parkvej 150;2610;Rødovre;0175 +Samsø;Søtofte 10;8305;Samsø;0741 +Silkeborg;Søvej 1;8600;Silkeborg;0740 +Skanderborg;Skanderborg Fælled 1;8660;Skanderborg;0746 +Skive;Torvegade 10;7800;Skive;0779 +Slagelse;Rådhuspladsen 11;4200;Slagelse;0330 +Solrød;Solrød Center 1;2680;Solrød Strand;0269 +Sorø;Rådhusvej 8;4180;Sorø;0340 +Stevns;Rådhuspladsen 4;4660;Store Heddinge;0336 +Struer;Østergade 11-15;7600;Struer;0671 +Svendborg;Ramsherred 5;5700;Svendborg;0479 +Syddjurs;Lundbergsvej 2;8400;Ebbeltoft;0706 +Sønderborg;Rådhustorvet 10;6400;Sønderborg;0540 +Thisted;Asylgade 30;7700;Thisted;0787 +Tønder;Kongevej 57;6270;Tønder;0550 +Tårnby;Amager Landevej 76;2770;Kastrup;0185 +Vallensbæk;Vallensbæk Stationstorv 100;2665;Vallensbæk Strand;0187 +Varde;Bytoften 2;6800;Varde;0573 +Vejen;Rådhuspassagen 3;6600;Vejen;0575 +Vejle;Skolegade 1;7100;Vejle;0630 +Vesthimmerland;Vestre Boulevard 7;9600;Aars;0820 +Viborg;Prinsens Alle 5;8800;Viborg;0791 +Vordingborg;Valdemarsgade 43;4760;Vordingborg;0390 +Ærø;Statene 2;5970;Ærøskøbing;0492 +Aabenraa;Skelbækvej 2;6200;Aabenraa;0580 +Aalborg;Boulevarden 13;9000;Aalborg;0851 +Aarhus;Rådhuspladsen 2;8000;Aarhus C;0751 diff --git a/members/migrations/0044_activity_address.py b/members/migrations/0044_activity_address.py new file mode 100644 index 00000000..81a61e58 --- /dev/null +++ b/members/migrations/0044_activity_address.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2 on 2023-09-24 13:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("members", "0043_alter_union_name"), + ] + + operations = [ + migrations.AddField( + model_name="activity", + name="address", + field=models.ForeignKey( + default=1, + on_delete=django.db.models.deletion.PROTECT, + to="members.address", + verbose_name="Adresse", + ), + ), + ] diff --git a/members/migrations/0045_address_descriptiontext.py b/members/migrations/0045_address_descriptiontext.py new file mode 100644 index 00000000..2d254a4c --- /dev/null +++ b/members/migrations/0045_address_descriptiontext.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2 on 2023-09-24 14:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("members", "0044_activity_address"), + ] + + operations = [ + migrations.AddField( + model_name="address", + name="descriptiontext", + field=models.CharField( + blank=True, max_length=100, verbose_name="Beskrivelse" + ), + ), + ] diff --git a/members/migrations/0046_address_dawa_category_alter_address_region.py b/members/migrations/0046_address_dawa_category_alter_address_region.py new file mode 100644 index 00000000..ec526712 --- /dev/null +++ b/members/migrations/0046_address_dawa_category_alter_address_region.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2 on 2024-02-19 20:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("members", "0045_address_descriptiontext"), + ] + + operations = [ + migrations.AddField( + model_name="address", + name="dawa_category", + field=models.CharField( + blank=True, max_length=1, verbose_name="DAWA kategori" + ), + ), + migrations.AlterField( + model_name="address", + name="region", + field=models.CharField( + choices=[ + ("Region Syddanmark", "Syddanmark"), + ("Region Hovedstaden", "Hovedstaden"), + ("Region Nordjylland", "Nordjylland"), + ("Region Midtjylland", "Midtjylland"), + ("Region Sjælland", "Sjælland"), + ("Online", "Online"), + ], + max_length=20, + verbose_name="Region", + ), + ), + ] diff --git a/members/migrations/0047_alter_emailitem_options_and_more.py b/members/migrations/0047_alter_emailitem_options_and_more.py new file mode 100644 index 00000000..7de95436 --- /dev/null +++ b/members/migrations/0047_alter_emailitem_options_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-05-23 08:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0046_alter_department_options_alter_union_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="emailitem", + options={ + "ordering": ["-created_dtm"], + "verbose_name": "Email", + "verbose_name_plural": "Emails", + }, + ), + migrations.RenameField( + model_name="emailitem", + old_name="reciever", + new_name="receiver", + ), + ] diff --git a/members/migrations/0047_department_has_waiting_list.py b/members/migrations/0047_department_has_waiting_list.py new file mode 100644 index 00000000..66328c02 --- /dev/null +++ b/members/migrations/0047_department_has_waiting_list.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-04-17 19:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0046_alter_department_options_alter_union_options"), + ] + + operations = [ + migrations.AddField( + model_name="department", + name="has_waiting_list", + field=models.BooleanField(default=True, verbose_name="Brug af venteliste"), + ), + ] diff --git a/members/migrations/0047_merge_20240329_2131.py b/members/migrations/0047_merge_20240329_2131.py new file mode 100644 index 00000000..27897e00 --- /dev/null +++ b/members/migrations/0047_merge_20240329_2131.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2 on 2024-03-29 20:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("members", "0045_activityinvite_extra_email_info_and_more"), + ("members", "0046_address_dawa_category_alter_address_region"), + ] + + operations = [] diff --git a/members/migrations/0048_alter_address_dawa_overwrite.py b/members/migrations/0048_alter_address_dawa_overwrite.py new file mode 100644 index 00000000..00572062 --- /dev/null +++ b/members/migrations/0048_alter_address_dawa_overwrite.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2 on 2024-03-29 20:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("members", "0047_merge_20240329_2131"), + ] + + operations = [ + migrations.AlterField( + model_name="address", + name="dawa_overwrite", + field=models.BooleanField( + default=False, + help_text="\n Lader dig gemme en anden længde- og breddegrad end oplyst fra DAWA (hvor vi henter adressedata). Spørg os i #medlemsssystem_support på Slack hvis du mangler hjælp.\n ", + verbose_name="Overskriv DAWA", + ), + ), + ] diff --git a/members/migrations/0048_merge_20240526_1518.py b/members/migrations/0048_merge_20240526_1518.py new file mode 100644 index 00000000..fc15272f --- /dev/null +++ b/members/migrations/0048_merge_20240526_1518.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.11 on 2024-05-26 13:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0047_alter_emailitem_options_and_more"), + ("members", "0047_department_has_waiting_list"), + ] + + operations = [] diff --git a/members/migrations/0049_merge_20240407_1605.py b/members/migrations/0049_merge_20240407_1605.py new file mode 100644 index 00000000..aac3d819 --- /dev/null +++ b/members/migrations/0049_merge_20240407_1605.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.11 on 2024-04-07 14:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("members", "0046_alter_department_options_alter_union_options"), + ("members", "0048_alter_address_dawa_overwrite"), + ] + + operations = [] diff --git a/members/migrations/0050_merge_20240526_1736.py b/members/migrations/0050_merge_20240526_1736.py new file mode 100644 index 00000000..23844799 --- /dev/null +++ b/members/migrations/0050_merge_20240526_1736.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.11 on 2024-05-26 15:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0048_merge_20240526_1518"), + ("members", "0049_merge_20240407_1605"), + ] + + operations = [] diff --git a/members/migrations/0051_alter_address_region.py b/members/migrations/0051_alter_address_region.py new file mode 100644 index 00000000..38a9aed6 --- /dev/null +++ b/members/migrations/0051_alter_address_region.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.11 on 2024-05-26 15:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0050_merge_20240526_1736"), + ] + + operations = [ + migrations.AlterField( + model_name="address", + name="region", + field=models.CharField( + choices=[ + ("Region Syddanmark", "Syddanmark"), + ("Region Hovedstaden", "Hovedstaden"), + ("Region Nordjylland", "Nordjylland"), + ("Region Midtjylland", "Midtjylland"), + ("Region Sjælland", "Sjælland"), + ("Online", "Online"), + ("Andet", "Andet"), + ], + max_length=20, + verbose_name="Region", + ), + ), + ] diff --git a/members/migrations/0052_alter_activity_address.py b/members/migrations/0052_alter_activity_address.py new file mode 100644 index 00000000..99f71e3f --- /dev/null +++ b/members/migrations/0052_alter_activity_address.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2024-05-26 16:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0051_alter_address_region"), + ] + + operations = [ + migrations.AlterField( + model_name="activity", + name="address", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="members.address", + verbose_name="Adresse", + ), + ), + ] diff --git a/members/migrations/0053_activity_visible_activity_visible_from.py b/members/migrations/0053_activity_visible_activity_visible_from.py new file mode 100644 index 00000000..5989d0f5 --- /dev/null +++ b/members/migrations/0053_activity_visible_activity_visible_from.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.11 on 2024-06-17 19:46 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0052_alter_activity_address"), + ] + + operations = [ + migrations.AddField( + model_name="activity", + name="visible", + field=models.BooleanField( + default=True, + help_text="Vises i denne aktivtet. Kan bruges sammen med feltet 'Aktiviteten er synlig fra'", + verbose_name="Vises denne aktivitet", + ), + ), + migrations.AddField( + model_name="activity", + name="visible_from", + field=models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="Aktiviteten er synlig fra", + ), + ), + ] diff --git a/members/migrations/0053_alter_emailitem_options.py b/members/migrations/0053_alter_emailitem_options.py new file mode 100644 index 00000000..20ccf965 --- /dev/null +++ b/members/migrations/0053_alter_emailitem_options.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.11 on 2024-05-26 17:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0052_alter_activity_address"), + ] + + operations = [ + migrations.AlterModelOptions( + name="emailitem", + options={ + "ordering": ["-created_dtm"], + "verbose_name": "Afsendt email", + "verbose_name_plural": "Afsendte emails", + }, + ), + ] diff --git a/members/migrations/0054_merge_20240617_2150.py b/members/migrations/0054_merge_20240617_2150.py new file mode 100644 index 00000000..cfee731d --- /dev/null +++ b/members/migrations/0054_merge_20240617_2150.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.11 on 2024-06-17 19:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0053_activity_visible_activity_visible_from"), + ("members", "0053_alter_emailitem_options"), + ] + + operations = [] diff --git a/members/migrations/0055_rename_union_email_union_email.py b/members/migrations/0055_rename_union_email_union_email.py new file mode 100644 index 00000000..1d2b70b3 --- /dev/null +++ b/members/migrations/0055_rename_union_email_union_email.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-08-31 11:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0054_merge_20240617_2150"), + ] + + operations = [ + migrations.RenameField( + model_name="union", + old_name="union_email", + new_name="email", + ), + ] diff --git a/members/migrations/0056_alter_activityinvite_activity.py b/members/migrations/0056_alter_activityinvite_activity.py new file mode 100644 index 00000000..dbebc62f --- /dev/null +++ b/members/migrations/0056_alter_activityinvite_activity.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-10-27 09:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0055_rename_union_email_union_email"), + ] + + operations = [ + migrations.AlterField( + model_name="activityinvite", + name="activity", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + to="members.activity", + verbose_name="Aktivitet", + ), + ), + ] diff --git a/members/migrations/0057_municipality.py b/members/migrations/0057_municipality.py new file mode 100644 index 00000000..e940260a --- /dev/null +++ b/members/migrations/0057_municipality.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.16 on 2024-10-27 13:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0056_alter_activityinvite_activity"), + ] + + operations = [ + migrations.CreateModel( + name="Municipality", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "municipality", + models.CharField(max_length=255, verbose_name="Kommune"), + ), + ("address", models.CharField(max_length=255, verbose_name="Adresse")), + ("zipcode", models.CharField(max_length=10, verbose_name="Postnr")), + ("city", models.CharField(max_length=100, verbose_name="By")), + ("email", models.EmailField(max_length=254, verbose_name="E-mail")), + ], + options={ + "verbose_name": "Kommune", + "verbose_name_plural": "Kommuner", + "ordering": ["municipality"], + }, + ), + ] diff --git a/members/migrations/0058_alter_municipality_options_remove_municipality_email_and_more.py b/members/migrations/0058_alter_municipality_options_remove_municipality_email_and_more.py new file mode 100644 index 00000000..f89f8562 --- /dev/null +++ b/members/migrations/0058_alter_municipality_options_remove_municipality_email_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.16 on 2024-10-27 14:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0057_municipality"), + ] + + operations = [ + migrations.AlterModelOptions( + name="municipality", + options={ + "ordering": ["name"], + "verbose_name": "Kommune", + "verbose_name_plural": "Kommuner", + }, + ), + migrations.RemoveField( + model_name="municipality", + name="email", + ), + migrations.RemoveField( + model_name="municipality", + name="municipality", + ), + migrations.AddField( + model_name="municipality", + name="dawa_id", + field=models.CharField(blank=True, max_length=200, verbose_name="DAWA id"), + ), + migrations.AddField( + model_name="municipality", + name="name", + field=models.CharField(default="", max_length=255, verbose_name="Navn"), + ), + ] diff --git a/members/migrations/0058_alter_union_options_union_gl_account.py b/members/migrations/0058_alter_union_options_union_gl_account.py new file mode 100644 index 00000000..ea25f9c6 --- /dev/null +++ b/members/migrations/0058_alter_union_options_union_gl_account.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.11 on 2024-11-13 19:56 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0057_municipality"), + ] + + operations = [ + migrations.AlterModelOptions( + name="union", + options={ + "ordering": ["name"], + "permissions": ( + ("view_all_unions", "Can view all Foreninger"), + ("showledgeraccount", "Show General Ledger Account"), + ), + "verbose_name": "Forening", + "verbose_name_plural": "Foreninger", + }, + ), + migrations.AddField( + model_name="union", + name="gl_account", + field=models.CharField( + blank=True, + help_text="Kontonummer i formatet 1234", + max_length=4, + validators=[ + django.core.validators.RegexValidator( + message="Indtast kontonummer i det rigtige format.", + regex="^[0-9]{4}", + ) + ], + verbose_name="Finanskonto:", + ), + ), + ] diff --git a/members/migrations/0059_merge_20241113_2111.py b/members/migrations/0059_merge_20241113_2111.py new file mode 100644 index 00000000..470047be --- /dev/null +++ b/members/migrations/0059_merge_20241113_2111.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.11 on 2024-11-13 20:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "members", + "0058_alter_municipality_options_remove_municipality_email_and_more", + ), + ("members", "0058_alter_union_options_union_gl_account"), + ] + + operations = [] diff --git a/members/migrations/0060_alter_union_options.py b/members/migrations/0060_alter_union_options.py new file mode 100644 index 00000000..23fedeb6 --- /dev/null +++ b/members/migrations/0060_alter_union_options.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.11 on 2024-11-13 21:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("members", "0059_merge_20241113_2111"), + ] + + operations = [ + migrations.AlterModelOptions( + name="union", + options={ + "ordering": ["name"], + "permissions": ( + ("view_all_unions", "Can view all Foreninger"), + ("show_ledger_account", "Show General Ledger Account"), + ), + "verbose_name": "Forening", + "verbose_name_plural": "Foreninger", + }, + ), + ] diff --git a/members/models/__init__.py b/members/models/__init__.py index 00fa389a..85e25693 100644 --- a/members/models/__init__.py +++ b/members/models/__init__.py @@ -15,6 +15,7 @@ import members.models.equipment import members.models.equipmentloan import members.models.family +import members.models.municipality import members.models.notification import members.models.payment import members.models.person @@ -43,6 +44,7 @@ from .equipment import Equipment from .equipmentloan import EquipmentLoan from .family import Family +from .municipality import Municipality from .notification import Notification from .payment import Payment from .person import Person @@ -73,6 +75,7 @@ EquipmentLoan, Family, gatherDayliStatistics, + Municipality, Notification, Payment, Person, diff --git a/members/models/activity.py b/members/models/activity.py index 32c46454..cc8b7ae5 100644 --- a/members/models/activity.py +++ b/members/models/activity.py @@ -71,12 +71,29 @@ class Meta: member_justified = models.BooleanField( "Aktiviteten gør personen til medlem", default=True, help_text=help_temp ) + address = models.ForeignKey( + "Address", on_delete=models.PROTECT, verbose_name="Adresse", null=False + ) + visible_from = models.DateTimeField( + "Aktiviteten er synlig fra", null=False, blank=False, default=timezone.now + ) + visible = models.BooleanField( + "Vises denne aktivitet", + null=False, + blank=False, + default=True, + help_text="Vises i denne aktivtet. Kan bruges sammen med feltet 'Aktiviteten er synlig fra'", + ) def is_historic(self): return self.end_date < timezone.now() is_historic.short_description = "Historisk?" + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs + def __str__(self): return self.department.name + ", " + self.name @@ -97,16 +114,22 @@ def seats_left(self): def participants(self): return self.activityparticipant_set.count() + def invitations(self): + return self.activityinvite_set.count() + participants.short_description = "Deltagere" def get_min_amount(self, activitytype): min_amount = self.NO_MINIMUM_AMOUNT - if activitytype == "FORENINGSMEDLEMSKAB": - min_amount = self.MEMBERSHIP_MIN_AMOUNT + # Issue 1058: If activity is in the past then skip this check + # During activity creation, the end_date could have been left empty + if self.end_date and self.end_date > timezone.now().date(): + if activitytype == "FORENINGSMEDLEMSKAB": + min_amount = self.MEMBERSHIP_MIN_AMOUNT - if activitytype == "FORLØB": - min_amount = self.ACTIVITY_MIN_AMOUNT + if activitytype == "FORLØB": + min_amount = self.ACTIVITY_MIN_AMOUNT return min_amount @@ -119,5 +142,37 @@ def clean(self): f"Prisen er for lav. Denne type aktivitet skal koste mindst {min_amount} kr." ) + if self.start_date is None: + errors["start_date"] = "Der skal angives en startdato for aktiviteten" + + if self.end_date is None: + errors["end_date"] = "Der skal angives en slutdato for aktiviteten" + + if self.signup_closing is None: + errors["signup_closing"] = "Der skal angives en dato for tilmeldingsfrist" + + if ( + (self.start_date is not None) + and (self.end_date is not None) + and (self.start_date > self.end_date) + ): + errors["signup_closing"] = "Startdato skal være før aktivitetens slutdato" + + if ( + (self.signup_closing is not None) + and (self.end_date is not None) + and (self.signup_closing > self.end_date) + ): + errors["signup_closing"] = ( + "Tilmeldingsfristen skal være før aktiviteten slutter" + ) + if errors: raise ValidationError(errors) + + def delete(self, *args, **kwargs): + if self.invitations() > 0 or self.participants() > 0: + raise ValidationError( + f'Aktivitet "{self.name}" kan ikke slettes, da der er tilmeldte eller inviterede personer. Muligvis vil systemet skrive at aktiviteten er slettet, men det er den ikke.' + ) + super().delete(*args, **kwargs) diff --git a/members/models/activityinvite.py b/members/models/activityinvite.py index c521f014..f36ab510 100644 --- a/members/models/activityinvite.py +++ b/members/models/activityinvite.py @@ -22,7 +22,7 @@ class Meta: unique_together = ("activity", "person") activity = models.ForeignKey( - "Activity", on_delete=models.CASCADE, verbose_name="Aktivitet" + "Activity", on_delete=models.DO_NOTHING, verbose_name="Aktivitet" ) person = models.ForeignKey("Person", on_delete=models.CASCADE) invite_dtm = models.DateField("Inviteret", default=timezone.now) diff --git a/members/models/address.py b/members/models/address.py index f01bcd38..0c903645 100644 --- a/members/models/address.py +++ b/members/models/address.py @@ -5,6 +5,7 @@ from .department import Department from .union import Union +from .activity import Activity class Address(models.Model): @@ -26,6 +27,8 @@ class Meta: ("Region Nordjylland", "Nordjylland"), ("Region Midtjylland", "Midtjylland"), ("Region Sjælland", "Sjælland"), + ("Online", "Online"), + ("Andet", "Andet"), ) region = models.CharField("Region", choices=REGION_CHOICES, max_length=20) municipality = models.CharField("Kommune", max_length=100, blank=True) @@ -36,7 +39,7 @@ class Meta: "Breddegrad", blank=True, null=True, max_digits=9, decimal_places=6 ) help_temp = """ - Lader dig gemme en anden Længdegrad og breddegrad end den gemt i DAWA \ + Lader dig gemme en anden længde- og breddegrad end oplyst fra DAWA \ (hvor vi henter adressedata). \ Spørg os i #medlemsssystem_support på Slack hvis du mangler hjælp. """ @@ -44,10 +47,12 @@ class Meta: "Overskriv DAWA", default=False, help_text=help_temp ) dawa_id = models.CharField("DAWA id", max_length=200, blank=True) + dawa_category = models.CharField("DAWA kategori", max_length=1, blank=True) created_at = models.DateTimeField( "Oprettet", auto_now=False, auto_now_add=True, auto_created=True ) created_by = models.PositiveIntegerField("Oprettet af", default=0) + descriptiontext = models.CharField("Beskrivelse", max_length=100, blank=True) def __str__(self): address = f"{self.streetname} {self.housenumber}" @@ -68,10 +73,13 @@ def get_dawa_data(self): "https://dawa.aws.dk/datavask/adresser", params={"betegnelse": str(self)}, ) - if wash_resp.status_code != 200 or wash_resp.json()["kategori"] == "C": + _category = wash_resp.json()["kategori"] + # DAWA category "C" means that it was a low probability address match + if wash_resp.status_code != 200 or _category == "C": return False else: self.dawa_id = wash_resp.json()["resultater"][0]["adresse"]["id"] + self.dawa_category = _category data_resp = requests.request( "GET", @@ -115,6 +123,11 @@ def get_by_dawa_id(dawa_id): def get_user_addresses(user): if user.is_superuser or user.has_perm("members.view_all_departments"): return Address.objects.all() + if user.has_perm("members.view_all_departments"): + department_address_id = [ + department.address.id for department in Department.objects.all() + ] + department_id = [department.id for department in Department.objects.all()] else: department_address_id = [ department.address.id @@ -122,6 +135,12 @@ def get_user_addresses(user): adminuserinformation__user=user ) ] + department_id = [ + department.id + for department in Department.objects.filter( + adminuserinformation__user=user + ) + ] if user.has_perm("members.view_all_unions"): union_address_id = [union.address.id for union in Union.objects.all()] @@ -131,16 +150,32 @@ def get_user_addresses(user): for union in Union.objects.filter(adminuserinformation__user=user) ] - # Find all addresses not used by Union nor Department + activity_address_id = [] + for department in department_id: + for activity in Activity.objects.filter(department_id=department): + activity_address_id += [activity.address.id] + + # Find all addresses not used by Union nor Department nor Activity address_id_all = [address.id for address in Address.objects.all()] + department_address_id_all = [ department.address.id for department in Department.objects.all() ] union_address_id_all = [union.address.id for union in Union.objects.all()] + activity_address_id_all = [ + activity.address.id for activity in Activity.objects.all() + ] + address_unused_ids = list( set(address_id_all) - set(department_address_id_all) - set(union_address_id_all) + - set(activity_address_id_all) + ) + address_ids = ( + address_unused_ids + + department_address_id + + union_address_id + + activity_address_id ) - address_ids = address_unused_ids + department_address_id + union_address_id return Address.objects.filter(pk__in=address_ids) diff --git a/members/models/department.py b/members/models/department.py index 570a6d06..e903d951 100644 --- a/members/models/department.py +++ b/members/models/department.py @@ -28,6 +28,7 @@ class Meta: address = models.ForeignKey( "Address", on_delete=models.PROTECT, verbose_name="Adresse" ) + has_waiting_list = models.BooleanField("Brug af venteliste", default=True) updated_dtm = models.DateTimeField("Opdateret", auto_now=True) created = models.DateField( "Oprettet", diff --git a/members/models/emailitem.py b/members/models/emailitem.py index d39a7c0e..8add9cd3 100644 --- a/members/models/emailitem.py +++ b/members/models/emailitem.py @@ -3,20 +3,27 @@ from django.db import models from django.core.mail import send_mail from django.conf import settings +from django.urls import reverse from django.utils import timezone from django.utils.html import format_html from django.utils.html import escape +from django.utils.safestring import mark_safe import uuid class EmailItem(models.Model): + class Meta: + verbose_name = "Afsendt email" + verbose_name_plural = "Afsendte emails" + ordering = ["-created_dtm"] + person = models.ForeignKey( "Person", blank=True, null=True, on_delete=models.CASCADE ) family = models.ForeignKey( "Family", blank=True, null=True, on_delete=models.CASCADE ) - reciever = models.EmailField("Modtager", null=False) + receiver = models.EmailField("Modtager", null=False) template = models.ForeignKey( "EmailTemplate", null=True, on_delete=models.DO_NOTHING ) @@ -35,6 +42,29 @@ class EmailItem(models.Model): "Fejl i afsendelse", max_length=200, blank=True, editable=False ) + def activityName(self): + if self.activity is None: + return "" + else: + return self.activity.name + + activityName.short_description = "Aktivitet" + + def departmentName(self): + if self.department is not None: + return self.department.name + elif self.activity is not None: + return self.activity.department.name + else: + return "" + + departmentName.short_description = "Afdeling" + + def created_ymdhm(self): + return self.created_dtm.strftime("%Y-%m-%d %H:%M") + + created_ymdhm.short_description = "Oprettet" + def sent_ymdhm(self, format_as_html: bool): if self.sent_dtm is None: if format_as_html: @@ -84,6 +114,11 @@ def subject_and_email_text(self): subject_and_email_text.short_description = "Emne og email" + def email_link(self): + url = reverse("admin:members_emailitem_change", args=[self.pk]) + link = '%s' % (url, escape(self.subject)) + return mark_safe(link) + # send this email. Notice no checking of race condition, so this should be called by # cronscript and made sure the same mail is not sent multiple times in parallel def send(self): @@ -94,7 +129,7 @@ def send(self): self.subject, self.body_text, settings.SITE_CONTACT, - (self.reciever,), + (self.receiver,), html_message=self.body_html, ) except Exception as e: @@ -106,4 +141,4 @@ def send(self): self.save() def __str__(self): - return str(self.reciever) + " '" + self.subject + "'" + return str(self.receiver) + " '" + self.subject + "'" diff --git a/members/models/emailtemplate.py b/members/models/emailtemplate.py index e0651bac..4bd78063 100644 --- a/members/models/emailtemplate.py +++ b/members/models/emailtemplate.py @@ -38,63 +38,63 @@ def __str__(self): # If possible it will also be filled with: # person, family - # recievers is expected to be a list of Person, Family or strings (email adresses) + # receivers are expected to be a list of Person, Family or strings (email adresses) - def makeEmail(self, recievers, context, allow_multiple_emails=False): - if type(recievers) is not list: - recievers = [recievers] + def makeEmail(self, receivers, context, allow_multiple_emails=False): + if type(receivers) is not list: + receivers = [receivers] emails = [] - for reciever in recievers: - # each reciever must be Person, Family or string (email) + for receiver in receivers: + # each receiver must be Person, Family or string (email) # Note - string specifically removed. We use family.dont_send_mails to make sure # we dont send unwanted mails. - if type(reciever) not in ( + if type(receiver) not in ( members.models.person.Person, members.models.family.Family, members.models.department.Department, ): raise Exception( - "Reciever must be of type Person or Family not " - + str(type(reciever)) + "Receiver must be of type Person or Family not " + + str(type(receiver)) ) - # figure out reciever - if type(reciever) is str: + # figure out receiver + if type(receiver) is str: # check if family blacklisted. (TODO) - destination_address = reciever - elif type(reciever) is members.models.person.Person: + destination_address = receiver + elif type(receiver) is members.models.person.Person: # skip if family does not want email - if reciever.family.dont_send_mails: + if receiver.family.dont_send_mails: continue - context["person"] = reciever - destination_address = reciever.email - elif type(reciever) is members.models.family.Family: + context["person"] = receiver + destination_address = receiver.email + elif type(receiver) is members.models.family.Family: # skip if family does not want email - if reciever.dont_send_mails: + if receiver.dont_send_mails: continue - context["family"] = reciever - destination_address = reciever.email - elif type(reciever) is members.models.department.Department: - context["department"] = reciever - destination_address = reciever.department_email + context["family"] = receiver + destination_address = receiver.email + elif type(receiver) is members.models.department.Department: + context["department"] = receiver + destination_address = receiver.department_email # figure out Person and Family is applicable - if type(reciever) is members.models.person.Person: - person = reciever + if type(receiver) is members.models.person.Person: + person = receiver elif "person" in context: person = context["person"] else: person = None # figure out family - if type(reciever) is members.models.family.Family: - family = reciever - elif type(reciever) is members.models.person.Person: - family = reciever.family + if type(receiver) is members.models.family.Family: + family = receiver + elif type(receiver) is members.models.person.Person: + family = receiver.family elif "family" in context: family = context["family"] else: @@ -138,7 +138,7 @@ def makeEmail(self, recievers, context, allow_multiple_emails=False): allow_multiple_emails or members.models.emailitem.EmailItem.objects.filter( person=person, - reciever=destination_address, + receiver=destination_address, activity=activity, template=self, department=department, @@ -147,7 +147,7 @@ def makeEmail(self, recievers, context, allow_multiple_emails=False): ): email = members.models.emailitem.EmailItem.objects.create( template=self, - reciever=destination_address, + receiver=destination_address, person=person, family=family, activity=activity, diff --git a/members/models/municipality.py b/members/models/municipality.py new file mode 100644 index 00000000..66d0c764 --- /dev/null +++ b/members/models/municipality.py @@ -0,0 +1,17 @@ +from django.db import models + + +class Municipality(models.Model): + name = models.CharField(max_length=255, verbose_name="Navn", default="") + address = models.CharField(max_length=255, verbose_name="Adresse") + zipcode = models.CharField(max_length=10, verbose_name="Postnr") + city = models.CharField(max_length=100, verbose_name="By") + dawa_id = models.CharField("DAWA id", max_length=200, blank=True) + + def __str__(self): + return f"{self.name}, {self.zipcode} {self.city}" + + class Meta: + verbose_name = "Kommune" + verbose_name_plural = "Kommuner" + ordering = ["name"] diff --git a/members/models/union.py b/members/models/union.py index f7c8eefa..6846a48d 100644 --- a/members/models/union.py +++ b/members/models/union.py @@ -10,7 +10,10 @@ class Meta: verbose_name_plural = "Foreninger" verbose_name = "Forening" ordering = ["name"] - permissions = (("view_all_unions", "Can view all Foreninger"),) + permissions = ( + ("view_all_unions", "Can view all Foreninger"), + ("show_ledger_account", "Show General Ledger Account"), + ) help_union = """Vi tilføjer automatisk "Coding Pirates" foran navnet når vi nævner det de fleste steder på siden.""" name = models.CharField("Foreningens navn", max_length=200, help_text=help_union) @@ -55,7 +58,7 @@ class Meta: ) secretary_old = models.CharField("Sekretær", max_length=200, blank=True) secretary_email_old = models.EmailField("Sekretærens email", blank=True) - union_email = models.EmailField("Foreningens email", blank=True) + email = models.EmailField("Foreningens email", blank=True) statues = models.URLField("Link til gældende vedtægter", blank=True) founded_at = models.DateField("Stiftet", blank=True, null=True) closed_at = models.DateField( @@ -88,6 +91,18 @@ class Meta: ) ], ) + gl_account = models.CharField( + "Finanskonto:", + max_length=4, + blank=True, + help_text="Kontonummer i formatet 1234", + validators=[ + RegexValidator( + regex="^[0-9]{4}", + message="Indtast kontonummer i det rigtige format.", + ) + ], + ) def __str__(self): return self.name diff --git a/members/schema.py b/members/schema.py index f6b84130..7633b6fb 100644 --- a/members/schema.py +++ b/members/schema.py @@ -36,7 +36,7 @@ class Meta: class UnionType(DjangoObjectType): class Meta: model = Union - exclude = ("bank_main_org", "bank_account") + exclude = ("bank_main_org", "bank_account", "gl_account") class DepartmentType(DjangoObjectType): diff --git a/members/static/members/js/copy_to_clipboard.js b/members/static/members/js/copy_to_clipboard.js new file mode 100644 index 00000000..04dfa9a3 --- /dev/null +++ b/members/static/members/js/copy_to_clipboard.js @@ -0,0 +1,13 @@ +document.addEventListener('DOMContentLoaded', function() { + const buttons = document.querySelectorAll('.copy-btn'); + buttons.forEach(button => { + button.addEventListener('click', function() { + const url = button.getAttribute('data-url'); + navigator.clipboard.writeText(url).then(function() { + alert('URL copied to clipboard!'); + }, function(err) { + console.error('Could not copy text: ', err); + }); + }); + }); +}); \ No newline at end of file diff --git a/members/templates/admin/extend_invitations.html b/members/templates/admin/extend_invitations.html new file mode 100644 index 00000000..2152a7ff --- /dev/null +++ b/members/templates/admin/extend_invitations.html @@ -0,0 +1,73 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_modify %} + +{% block extrahead %}{{ block.super }} + + +{{mass_confirmation_form.media}} +{{ media }} +{% endblock %} + +{% block extrastyle %}{{ block.super }} + + + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content_title %}

Forlæng valgte invitationer

{% endblock %} + +{% block content %} +
+
+

Bemærk: Der sendes ikke ny mail

+
+
+ {% csrf_token %} + + {% for obj in queryset %} + + {% endfor %} + +

Forlæng udløbsdato for invitation(er)

+
+

+ Angiv ny dato: + +

+
+ +

Deltagere

+
+

Forlæng invitation for følgende ({{ persons.count }}) personer:

+
    + {% for invitation in invitations %} +
  • {{ invitation.person.name }}
  • + {% endfor %} +
+
+ + {% for field in mass_confirmation_form %} +
+ {{ field.errors }} + {{ field.label_tag }} {{ field }} +
+ {% endfor %} + +
+
+ + +
+
+
+
+
+{% endblock %} diff --git a/members/templates/members/activities.html b/members/templates/members/activities.html index f607d8e0..447cd798 100644 --- a/members/templates/members/activities.html +++ b/members/templates/members/activities.html @@ -61,20 +61,23 @@

Invitationer

- {% if invite.activity.placename %} - {{ invite.activity.placename }}
+ {% if invite.activity.address.placename %} + {{ invite.activity.address.placename }}
{% endif %} - {{ invite.activity.streetname }} - {{ invite.activity.housenumber }} - {% if invite.activity.floor %} - {{ invite.activity.floor }} + {{ invite.activity.address.streetname }} + {{ invite.activity.address.housenumber }} + {% if invite.activity.address.floor %} + {{ invite.activity.address.floor }} {% endif %} - {% if invite.activity.door %} - {{ invite.activity.door }} + {% if invite.activity.address.door %} + {{ invite.activity.address.door }} {% endif %}
- {{ invite.activity.zipcode }} - {{ invite.activity.city }} + {% if invite.activity.address.placename %} + {{ invite.activity.address.placename }}
+ {% endif %} + {{ invite.activity.address.zipcode }} + {{ invite.activity.address.city }} {{ invite.activity.min_age }} - {{ invite.activity.max_age }} @@ -115,7 +118,7 @@

Invitationer

{% if current_activities %}

Nuværende og kommende aktiviteter

- {% regroup current_activities by department.address.region as departments_region %} + {% regroup current_activities by address.region as departments_region %}

Denne liste indeholder igangværende og kommende aktiviteter.
For aktiviteter med fri tilmelding, kan I tilmelde Jer direkte, og ellers kan I opskrive Jer på venteliste til afdelingen. @@ -161,20 +164,23 @@

Nuværende og kommende aktiviteter

{{ activity.name }} - {% if activity.placename %} - {{ activity.placename }}
+ {% if activity.address.descriptiontext %} + {{ activity.address.descriptiontext }}
{% endif %} - {{ activity.streetname }} - {{ activity.housenumber }} - {% if activity.floor %} - {{ activity.floor }} + {{ activity.address.streetname }} + {{ activity.address.housenumber }} + {% if activity.address.floor %} + {{ activity.address.floor }} {% endif %} - {% if activity.door %} - {{ activity.door }} + {% if activity.address.door %} + {{ activity.address.door }} {% endif %}
- {{ activity.zipcode }} - {{ activity.city }} + {% if activity.address.placename %} + {{ activity.address.placename }}
+ {% endif %} + {{ activity.address.zipcode }} + {{ activity.address.city }} {{ activity.min_age }} - {{ activity.max_age }} @@ -204,6 +210,12 @@

Nuværende og kommende aktiviteter

{{ activity.department.name }} opretter en ny aktivitet.

{% endif %} + {% if not activity.department.has_waiting_list %} +
+ Afdelingen bruger ikke venteliste, men aktiviteten er ikke åben for tilmeldinger (endnu) +
+ {% endif %} + {% for person in persons %} {% if activity.open_invite %} {% if activity.id in person.participating_activities %} @@ -211,6 +223,11 @@

Nuværende og kommende aktiviteter

{{ person.person.firstname }} er tilmeldt {% else %} + {% if activity.seats_left == 0 %} +
+ Udsolgt - kan ikke tilmelde {{ person.person.firstname }} +
+ {% else %} {% if person.person in activity.persons %} Nuværende og kommende aktiviteter Tilmeld {{ person.person.firstname }} {% endif %} + {% endif %} {% endif %} {% else %} {% if activity.department in person.departments_is_waiting %} @@ -226,12 +244,14 @@

Nuværende og kommende aktiviteter

{{ person.person.firstname }} er opskrevet {% else %} - - Opskriv {{ person.person.firstname }} på venteliste - + {% if activity.department.has_waitinglist %} + + Opskriv {{ person.person.firstname }} på venteliste + + {% endif %} {% endif %} {% endif %} {% endfor %} diff --git a/members/templates/members/activity_signup.html b/members/templates/members/activity_signup.html index d14f510d..39774ac7 100644 --- a/members/templates/members/activity_signup.html +++ b/members/templates/members/activity_signup.html @@ -10,13 +10,14 @@

{{activity.department.name}}: {{activity.name}}

Sted:
- {{activity.streetname}} {{activity.housenumber}}{%if activity.floor or activity.door%}, {%endif%} - {%if activity.floor %}{{activity.floor}}.{%endif%} - {%if activity.door%}{{activity.door}}{%endif%} + {{activity.address.streetname}} {{activity.address.housenumber}} + {%if activity.address.floor or activity.address.door%}, {%endif%} + {%if activity.address.floor %}{{activity.address.floor}}.{%endif%} + {%if activity.address.door%}{{activity.address.door}}{%endif%}
- {%if activity.placename%}{{activity.placename}}
{%endif%} - {{activity.zipcode}} {{activity.city}}
- {{activity.placenave}}
+ {%if activity.address.placename%}{{activity.address.placename}}
{%endif%} + {{activity.address.zipcode}} {{activity.address.city}}
+ {{activity.address.placenave}}
diff --git a/members/templates/members/department_signup.html b/members/templates/members/department_signup.html index 1aae2df8..95c3f364 100644 --- a/members/templates/members/department_signup.html +++ b/members/templates/members/department_signup.html @@ -40,7 +40,7 @@
Hvordan bliver man fjernet fra en venteliste?