From b695dd8054d035bc1438d523811d030a2057ddf9 Mon Sep 17 00:00:00 2001 From: st01765 Date: Thu, 5 Feb 2026 11:18:08 +1100 Subject: [PATCH] Created new models to replicate out of and split coord app Moved helpers and views to their respective new apps --- busManager/busManager/settings.py | 3 +- busManager/common/admin.py | 12 +- busManager/common/documents.py | 14 + busManager/common/models.py | 22 +- busManager/coord/views.py | 2 +- busManager/messaging/__init__.py | 0 busManager/messaging/apps.py | 6 + busManager/messaging/migrations/__init__.py | 0 busManager/messaging/services/email.py | 127 ++++++++ busManager/messaging/services/sms.py | 162 ++++++++++ busManager/messaging/tests.py | 3 + busManager/messaging/views.py | 3 + busManager/migration_scripts/copy_suburbs.py | 24 ++ busManager/transport/admin.py | 78 ++++- busManager/transport/admin_mixins.py | 54 ++++ busManager/transport/context_busroll.py | 73 +++++ busManager/transport/context_helpers.py | 88 ++++++ busManager/transport/forms.py | 23 ++ busManager/transport/models.py | 103 ++++++- busManager/transport/views.py | 19 +- busManager/traveller/admin.py | 110 ++++++- busManager/traveller/adminClone.py | 161 ++++++++++ busManager/traveller/admin_mixins.py | 60 ++++ busManager/traveller/context_helpers.py | 90 ++++++ busManager/traveller/models.py | 293 ++++++++++++++++++- busManager/traveller/views.py | 16 +- 26 files changed, 1536 insertions(+), 10 deletions(-) create mode 100644 busManager/common/documents.py create mode 100644 busManager/messaging/__init__.py create mode 100644 busManager/messaging/apps.py create mode 100644 busManager/messaging/migrations/__init__.py create mode 100644 busManager/messaging/services/email.py create mode 100644 busManager/messaging/services/sms.py create mode 100644 busManager/messaging/tests.py create mode 100644 busManager/messaging/views.py create mode 100644 busManager/migration_scripts/copy_suburbs.py create mode 100644 busManager/transport/admin_mixins.py create mode 100644 busManager/transport/context_busroll.py create mode 100644 busManager/transport/context_helpers.py create mode 100644 busManager/transport/forms.py create mode 100644 busManager/traveller/adminClone.py create mode 100644 busManager/traveller/admin_mixins.py create mode 100644 busManager/traveller/context_helpers.py diff --git a/busManager/busManager/settings.py b/busManager/busManager/settings.py index 2d15238..010d87c 100644 --- a/busManager/busManager/settings.py +++ b/busManager/busManager/settings.py @@ -46,7 +46,8 @@ INSTALLED_APPS = [ 'rangefilter', 'common', 'transport', - 'traveller' + 'traveller', + 'messaging' ] if platform.system() == "Linux": diff --git a/busManager/common/admin.py b/busManager/common/admin.py index 8c38f3f..2834b89 100644 --- a/busManager/common/admin.py +++ b/busManager/common/admin.py @@ -1,3 +1,13 @@ from django.contrib import admin +from import_export.admin import ImportExportModelAdmin -# Register your models here. +from common.models import Suburb + + +class MyImportExportModelAdmin(ImportExportModelAdmin): + def has_import_permission(self, request): + return request.user.is_superuser + +@admin.register(Suburb) +class SuburbsAdmin(MyImportExportModelAdmin, admin.ModelAdmin): + list_filter = ["state"] \ No newline at end of file diff --git a/busManager/common/documents.py b/busManager/common/documents.py new file mode 100644 index 0000000..002ec93 --- /dev/null +++ b/busManager/common/documents.py @@ -0,0 +1,14 @@ +from io import BytesIO + +from django.http import HttpResponse +from django.template.loader import get_template +from xhtml2pdf import pisa + + +def render_to_pdf(template, context): + html = get_template(template).render(context) + result = BytesIO() + pdf = pisa.pisaDocument(BytesIO(html.encode("ISO-8859-1")), result) + if pdf.err: + return HttpResponse("Invalid PDF", status_code=400, content_type='text/plan') + return HttpResponse(result.getvalue(), content_type='application/pdf') \ No newline at end of file diff --git a/busManager/common/models.py b/busManager/common/models.py index 71a8362..a9ff573 100644 --- a/busManager/common/models.py +++ b/busManager/common/models.py @@ -1,3 +1,23 @@ from django.db import models -# Create your models here. +class Suburb(models.Model): + STATE = [ + ("VIC", "Victoria"), + ("NSW", "New South Wales"), + ("SA", "South Australia"), + ("ACT", "Australia Capital Territory"), + ("QLD", "Queensland"), + ("NT", "Northern Territory"), + ("WA", "Western Australia"), + ("TAS", "Tasmania"), + ] + name = models.CharField(max_length=30, unique=True) + state = models.CharField(max_length=3, choices=STATE) + postcode = models.PositiveSmallIntegerField() + distance = models.PositiveSmallIntegerField(blank=True, null=True) + + class Meta: + ordering = ["name"] + + def __str__(self): + return f"{self.name}, {self.state} {self.postcode}" \ No newline at end of file diff --git a/busManager/coord/views.py b/busManager/coord/views.py index 5134cd2..aa13f95 100644 --- a/busManager/coord/views.py +++ b/busManager/coord/views.py @@ -21,7 +21,7 @@ def emergency_contacts(request): def bus_roll(request): return render_to_pdf('reports/bus_roll.html', bus_roll_context()) - +@staff_member_required def sms_message(request, queryset): if request.method == 'POST': form = SMSForm(request.POST) diff --git a/busManager/messaging/__init__.py b/busManager/messaging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/busManager/messaging/apps.py b/busManager/messaging/apps.py new file mode 100644 index 0000000..a1b00c5 --- /dev/null +++ b/busManager/messaging/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MessagingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'messaging' diff --git a/busManager/messaging/migrations/__init__.py b/busManager/messaging/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/busManager/messaging/services/email.py b/busManager/messaging/services/email.py new file mode 100644 index 0000000..02eccea --- /dev/null +++ b/busManager/messaging/services/email.py @@ -0,0 +1,127 @@ +from datetime import date + +from django.core.mail import EmailMessage + +from common.documents import render_to_pdf +from transport.context_busroll import bus_roll_context +from transport.context_helpers import emergency_contacts_context +from transport.models import Company +from traveller.context_helpers import school_roll_context +from traveller.models import School + + +def _getBCC(request): + if request.user.email: + return [request.user.email] + return [] + + +def email_companies_bus_roll(request, query_set=None): + html_template = 'reports/bus_roll.html' + context = bus_roll_context(query_set) + + for company in Company.objects.all(): + if not company.contact_email: + continue + company_route = [] + + for route in context.get("routes"): + if route.get("bus").company == company: + company_route.append(route) + if not company_route: + continue + company_context = {'routes': company_route} + pdf = render_to_pdf(html_template, company_context) + + subject = "Echuca Schools Bus Roll" + message = f"A new bus roll for {company.name} has been generated" + email_from = "bus.manager@education.vic.gov.au" + recipient = [company.contact_email] + email = EmailMessage(subject, message, email_from, recipient, _getBCC(request)) + email.attach(f"school_bus_roll_{date.today()}.pdf", pdf.content) + email.send(fail_silently=True) + + return render_to_pdf(html_template, context) + + +def email_companies_emergency_contacts(request, query_set=None): + html_template = 'reports/emergency_contacts.html' + context = emergency_contacts_context(query_set) + + for company in Company.objects.all(): + if not company.contact_email: + continue + company_route = [] + + for route in context.get("routes"): + if route.get("bus").company == company: + company_route.append(route) + if not company_route: + continue + company_context = {'routes': company_route} + pdf = render_to_pdf(html_template, company_context) + + subject = "Echuca School Buses Emergency Contacts" + message = f"A new emergency contact list for {company.name} has been generated" + email_from = "bus.manager@education.vic.gov.au" + recipient = [company.contact_email] + email = EmailMessage(subject, message, email_from, recipient, _getBCC(request)) + email.attach(f"school_bus_roll_{date.today()}.pdf", pdf.content) + email.send(fail_silently=True) + + return render_to_pdf(html_template, context) + + +def email_school_roll(request, query_set): + html_template = 'reports/school_roll.html' + context = school_roll_context(query_set) + + for school in School.objects.all(): + if not school.email: + continue + school_route = [] + for school_context in context.get("schools"): + if school_context.get("name") == school.name: + school_route.append(school_context) + if not school_route: + continue + school_context = {'schools': school_route} + pdf = render_to_pdf(html_template, school_context) + + subject = "Echuca Schools Bus Roll" + message = f"A new bus roll for {school.name} has been generated" + email_from = "bus.manager@education.vic.gov.au" + recipient = [school.email] + email = EmailMessage(subject, message, email_from, recipient, _getBCC(request)) + email.attach(f"school_bus_roll_{date.today()}.pdf", pdf.content) + email.send(fail_silently=True) + + return render_to_pdf(html_template, context) + + +def email_school_shuttle_roll(request, queryset): + html_template = 'reports/bus_roll.html' + + schools = [] + for shuttle in queryset: + if shuttle.school not in schools: + schools.append(shuttle.school) + for school in schools: + buses = [] + for shuttle in queryset: + if shuttle.school == school: + buses.append(shuttle.bus) + pdf = render_to_pdf(html_template, bus_roll_context(buses, include_bus_stops=False)) + + subject = "Echuca Schools Shuttle Roll" + message = f"A new shuttle roll for {school.name} has been generated" + email_from = "bus.manager@education.vic.gov.au" + recipient = [school.email] + email = EmailMessage(subject, message, email_from, recipient, _getBCC(request)) + email.attach(f"school_shuttle_roll_{date.today()}.pdf", pdf.content) + email.send(fail_silently=True) + buses = [] + for shuttle in queryset: + if shuttle.bus not in buses: + buses.append(shuttle.bus) + return render_to_pdf(html_template, bus_roll_context(buses, include_bus_stops=False)) \ No newline at end of file diff --git a/busManager/messaging/services/sms.py b/busManager/messaging/services/sms.py new file mode 100644 index 0000000..24e1aca --- /dev/null +++ b/busManager/messaging/services/sms.py @@ -0,0 +1,162 @@ +import requests +from django import forms +from django.conf import settings +from django.http import HttpResponseRedirect +from django.shortcuts import render + + +def _get_token(): + url = 'https://products.api.telstra.com/v2/oauth/token' + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded' + } + data = { + 'client_id': settings.TELSTRA_AUTH['client_id'], + 'client_secret': settings.TELSTRA_AUTH['client_secret'], + 'grant_type': 'client_credentials' + } + + result = requests.post(url, data=data, headers=headers) + if result.status_code != 200: + print("Bad request for telstra access_token:" + str(result.status_code)) + return None + return result.json()['access_token'] + + +def telstra_api_request(url, data=None, method="POST"): + url = 'https://products.api.telstra.com/' + url + + token = _get_token() + headers = { + 'Telstra-api-version': '3.x', + 'Content-Language': 'en-au', + 'Authorization': f'Bearer {token}', + 'Accept': 'application/json', + 'Accept-Charset': 'utf-8', + 'Content-Type': 'application/json' + } + result = requests.request(method, url, json=data, headers=headers) + + if result.status_code != 200: + print("Bad request:" + str(result.status_code)) + print(result.content) + return False, result.content + return True, result.json() + + +def _send_message(to, msg): + url = 'messaging/v3/messages' + data = { + 'to': to, + 'from': _get_virtual_numbers(), + 'messageContent': msg + } + result = telstra_api_request(url, data) + return result + + +def _get_virtual_numbers(): + url = 'messaging/v3/virtual-numbers' + success, result = telstra_api_request(url, method='GET') + if not success: + print("No number found") + success, result = telstra_api_request(url, method='POST') + if not success: + return None + return result['virtualNumber'] + + numbers = result['virtualNumbers'] + if numbers is None: + return None + return numbers[0]['virtualNumber'] + + +class SMSForm(forms.Form): + _selected_action = forms.CharField(widget=forms.MultipleHiddenInput) + send_to_parents = forms.BooleanField(required=False) + send_to_emergency_contacts = forms.BooleanField(required=False) + only_include_active_travellers = forms.BooleanField(initial=True, required=False) + message = forms.CharField(label="Message", max_length=160, widget=forms.Textarea) + + +def _get_numbers(request, queryset): + send_to_parents = bool(request.POST.get("send_to_parents")) + send_to_emergency_contacts = bool(request.POST.get("send_to_emergency_contacts")) + only_include_active_travellers = bool(request.POST.get("only_include_active_travellers")) + + numbers = [] + for traveller in queryset: + if only_include_active_travellers and not traveller._is_active(): + continue + for family in traveller.get_families(): + family_numbers, _ = family.get_parsed_numbers(parents=send_to_parents, emergency=send_to_emergency_contacts) + if family_numbers: + numbers = numbers + family_numbers + return list(set(numbers)) # Remove duplicates + + +def _family_context(queryset): + family_set = [] + for traveller in queryset: + families = traveller.get_families() + if len(families) == 0: + family_set.append({ + 'traveller': traveller.__str__(), + 'has_failed_number': True + }) + for family in families: + _, failed_numbers = family.get_parsed_numbers(True, True) + family_context = { + 'traveller': traveller.__str__(), + 'has_failed_number': len(failed_numbers) > 0 + } + if family.parent_A_phone: + family_context['parent_A'] = f"{family.parent_A_firstname} {family.parent_A_lastname} ({family.parent_A_phone})" + if family.parent_B_phone: + family_context['parent_B'] = f"{family.parent_B_firstname} {family.parent_B_lastname} ({family.parent_B_phone})" + if family.emergency_contact_A_phone: + family_context['contact_A'] = f"{family.emergency_contact_A_firstname} {family.emergency_contact_A_lastname} ({family.emergency_contact_A_phone})" + if family.emergency_contact_B_phone: + family_context['contact_B'] = f"{family.emergency_contact_B_firstname} {family.emergency_contact_B_lastname} ({family.emergency_contact_B_phone})" + family_set.append(family_context) + return family_set + + +def send_sms(send_sms_mixin, request, queryset): + if not settings.TELSTRA_AUTH: + send_sms_mixin.message_user(request, "Telstra auth not configured", level="WARNING") + return HttpResponseRedirect(request.get_full_path()) + + if 'send' in request.POST: + numbers = _get_numbers(request, queryset) + if len(numbers) > 500: + send_sms_mixin.message_user(request, f"SMS failed. Total phone numbers ({len(numbers)}) exceeds 500", level="WARNING") + return HttpResponseRedirect(request.get_full_path()) + if len(numbers) == 0: + send_sms_mixin.message_user(request, f"SMS failed. No numbers we selected", level="WARNING") + return HttpResponseRedirect(request.get_full_path()) + result = _send_message(numbers, request.POST["message"]) + send_sms_mixin.message_user(request, f"SMS has been sent to {len(numbers)} recipients") + return HttpResponseRedirect(request.get_full_path()) + + form = SMSForm(initial={'_selected_action': queryset.values_list('id', flat=True)}) + + family_set = _family_context(queryset) + + return render(request, 'admin/sms_form.html', context={'form': form, 'items': family_set}) + + +class SMSTestForm(forms.Form): + phone_number = forms.CharField(label="Phone number", max_length=20) + message = forms.CharField(label="Message", max_length=160, widget=forms.Textarea) + + +def send_sms_test(request): + + if not settings.TELSTRA_AUTH: + return None + + if request.method == "POST": + _send_message(request.POST["phone_number"], request.POST["message"]) + return None \ No newline at end of file diff --git a/busManager/messaging/tests.py b/busManager/messaging/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/busManager/messaging/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/busManager/messaging/views.py b/busManager/messaging/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/busManager/messaging/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/busManager/migration_scripts/copy_suburbs.py b/busManager/migration_scripts/copy_suburbs.py new file mode 100644 index 0000000..e506667 --- /dev/null +++ b/busManager/migration_scripts/copy_suburbs.py @@ -0,0 +1,24 @@ +import os +import sys + +import django + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Setup Django environment +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "busManager.settings") +django.setup() + +from coord.models import Suburb as OldSuburb +from common.models import Suburb as NewSuburb + +for old in OldSuburb.objects.all(): + NewSuburb.objects.create( + id=old.id, # preserve PK + name=old.name, + state=old.state, + postcode=old.postcode, + distance=old.distance + ) + +print(f"Copied {OldSuburb.objects.count()} suburbs to common_suburb") \ No newline at end of file diff --git a/busManager/transport/admin.py b/busManager/transport/admin.py index 8c38f3f..363649f 100644 --- a/busManager/transport/admin.py +++ b/busManager/transport/admin.py @@ -1,3 +1,79 @@ from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html +from django.utils.http import urlencode -# Register your models here. +from common.admin import MyImportExportModelAdmin +from transport.admin_mixins import BusRollMixin, ShuttleRollMixin +from transport.models import Driver, BusStop, Company, Bus, Shuttle + + +@admin.register(Company) +class CompanyAdmin(MyImportExportModelAdmin, admin.ModelAdmin): + list_display = ["name", "contact_name", "email", "buses"] + + def buses(self, obj): + count = obj.bus_set.count() + url = ( + reverse("admin:coord_bus_changelist") + + "?" + + urlencode({"company__id__exact": f"{obj.id}"}) + ) + return format_html('{} Buses', url, count) + + def email(self, obj): + return format_html('{}', obj.contact_email, obj.contact_email) + + +class DriverInline(admin.StackedInline): + model = Driver + extra = 0 + + +class BusStopInline(admin.TabularInline): + model = BusStop + extra = 0 + ordering = ("am_time",) + + +@admin.register(Bus) +class BusesAdmin(MyImportExportModelAdmin, admin.ModelAdmin, BusRollMixin): + list_filter = ["company"] + list_display = ["route_name", "company", "contract_number", "seating_capacity", "route_travellers"] + readonly_fields = ["traveller_count"] + actions = ["show_bus_roll", "show_bus_roll_on_date", "show_emergency_contacts", "sms_traveller_contacts", "email_bus_roll", "email_emergency_contacts"] + inlines = [DriverInline, BusStopInline] + fieldsets = [ + (None, {'fields': [ + "company", "route_name", "contract_number", "registration", + "traveller_count", "seating_capacity", "make", "model", "notes" + ]}) + ] + + def route_travellers(self, obj): + url = ( + reverse("admin:coord_traveller_changelist") + + "?" + + urlencode({"bus_stops__bus__id__exact": f"{obj.id}"}) + ) + return format_html('{} Travellers', url, obj.traveller_count()) + + +# @admin.register(BusStop) +class BusStopAdmin(MyImportExportModelAdmin, admin.ModelAdmin): + list_filter = ["bus__company", "bus__route_name"] + list_display = ["__str__", "am_time", "pm_time", "address"] + search_fields = ["bus__route_name", "address"] + +@admin.register(Shuttle) +class ShuttleAdmin(MyImportExportModelAdmin, admin.ModelAdmin, ShuttleRollMixin): + list_display = ["__str__", "school", "bus", "shuttle_travellers"] + actions = ["show_shuttle_roll", "email_shuttle_roll"] + + def shuttle_travellers(self, obj): + url = ( + reverse("admin:coord_traveller_changelist") + + "?" + + urlencode({"shuttle__id__exact": f"{obj.id}"}) + ) + return format_html('{} Travellers', url, obj.traveller_count()) \ No newline at end of file diff --git a/busManager/transport/admin_mixins.py b/busManager/transport/admin_mixins.py new file mode 100644 index 0000000..57cce1a --- /dev/null +++ b/busManager/transport/admin_mixins.py @@ -0,0 +1,54 @@ +from common.documents import render_to_pdf +from messaging.services.email import email_companies_bus_roll, email_companies_emergency_contacts, \ + email_school_shuttle_roll +from messaging.services.sms import send_sms +from transport.context_busroll import bus_roll_context +from transport.context_helpers import emergency_contacts_context +from transport.forms import roll_date_selector +from traveller.models import Traveller + + +class BusRollMixin: + + def show_bus_roll(self, request, queryset): + return render_to_pdf('reports/bus_roll.html', bus_roll_context(queryset)) + + def show_bus_roll_on_date(self, request, queryset): + return roll_date_selector(self, request, queryset) + + def show_emergency_contacts(self, request, queryset): + return render_to_pdf('reports/emergency_contacts.html', emergency_contacts_context(queryset)) + + def sms_traveller_contacts(self, request, queryset): + travellers = None + for bus in queryset: + query = Traveller.objects.filter(bus_stops__bus=bus).filter(is_active=True).distinct() + if travellers is None: + travellers = query + else: + travellers.union(query) + return send_sms(self, request, travellers) + + def email_bus_roll(self, request, queryset): + return email_companies_bus_roll(request, queryset) + + def email_emergency_contacts(self, request, queryset): + return email_companies_emergency_contacts(request, queryset) + + email_bus_roll.short_description = "Email Bus Roll to Company" + email_emergency_contacts.short_description = "Email Emergency Contacts to Company" + +class ShuttleRollMixin: + + def show_shuttle_roll(self, request, queryset): + if queryset is None: + buses = None + else: + buses = [] + for shuttle in queryset: + if shuttle.bus not in buses: + buses.append(shuttle.bus) + return render_to_pdf('reports/bus_roll.html', bus_roll_context(buses, include_bus_stops=False)) + + def email_shuttle_roll(self, request, queryset): + return email_school_shuttle_roll(request, queryset) \ No newline at end of file diff --git a/busManager/transport/context_busroll.py b/busManager/transport/context_busroll.py new file mode 100644 index 0000000..cc44aad --- /dev/null +++ b/busManager/transport/context_busroll.py @@ -0,0 +1,73 @@ +import datetime + +from transport.models import Bus, Shuttle, BusStop +from traveller.models import TravellerRoute, Traveller + + +def route_paged_context(bus, date=None): + table_header_size = 5 + page_max_size = 45 + page_size = 3 # Account for traveller numbers at the top of the first page + route_stops = [] + for bus_stop in BusStop.objects.filter(bus=bus): + traveller_routes = TravellerRoute.objects.filter(busStop=bus_stop) + traveller_list = [] + for trav_route in traveller_routes: + traveller = trav_route.traveller + if not traveller._is_active(date): + continue + is_fared = "---" + if traveller.eligibility_status == "2": + is_fared = "Y" + traveller_list.append({ + 'display': f"{traveller} ({traveller.get_year_level_display()}, {traveller.school.shortName})", + 'isFared': is_fared + }) + + stop_size = len(traveller_list) + page_break = False + page_size += table_header_size + stop_size + if page_size > page_max_size: + if len(route_stops) > 0: # Don't break the page if it's the first stop + page_break = True + page_size = table_header_size + stop_size + + route_stops.append({ + 'stop_num': bus_stop.get_stop_number(), + 'name': bus_stop.address, + 'am': bus_stop.am_time, + 'pm': bus_stop.pm_time, + 'travellers': traveller_list, + 'page_break': page_break + }) + return route_stops + +def shuttle_route_context(shuttle, date=None): + shuttle_travellers = [] + for traveller in Traveller.objects.filter(shuttle=shuttle): + if traveller._is_active(date): + shuttle_travellers.append({ + 'display': f"{traveller} ({traveller.get_year_level_display()}, {traveller.school})", + }) + return {'shuttle': shuttle, 'shuttle_travellers': shuttle_travellers, 'traveller_count': shuttle.traveller_count(date)} + +def bus_roll_context(queryset=None, include_bus_stops=True, date=None): + bus_routes = [] + if queryset is None: + buses = Bus.objects.all() + else: + buses = queryset + + for bus in buses: + route_stops = [] + if include_bus_stops: + route_stops = route_paged_context(bus=bus, date=date) + + shuttle_routes = [] + for shuttle in Shuttle.objects.filter(bus=bus): + shuttle_routes.append(shuttle_route_context(shuttle, date)) + + bus_routes.append({'name': bus.route_name, 'traveller_count': bus.traveller_count(date), 'seating_capacity': bus.seating_capacity, 'bus': bus, 'route_stops': route_stops, 'shuttle_routes': shuttle_routes}) + if date is None: + date = datetime.date.today() + return {'routes': bus_routes, 'date': date.strftime('%Y-%m-%d')} \ No newline at end of file diff --git a/busManager/transport/context_helpers.py b/busManager/transport/context_helpers.py new file mode 100644 index 0000000..34bac90 --- /dev/null +++ b/busManager/transport/context_helpers.py @@ -0,0 +1,88 @@ +from transport.models import Bus, Driver, BusStop, Shuttle +from traveller.models import TravellerRoute, Traveller + + +def bus_summary_context(): + bus_routes = [] + for bus in Bus.objects.all(): + + drivers = [] + for driver in Driver.objects.filter(bus=bus): + drivers.append(driver) + + stops = [] + for bus_stop in BusStop.objects.filter(bus=bus): + stops.append(bus_stop) + + traveller_count = 0 + for travellerRoute in TravellerRoute.objects.filter(busStop__bus=bus): + if travellerRoute.traveller._is_active(): + traveller_count += 1 + + shuttle_name = "" + shuttle_count = 0 + for shuttle in Shuttle.objects.filter(bus=bus): + if shuttle_name == "": + shuttle_name = shuttle.school.shortName + else: + shuttle_name += f", {shuttle.school.shortName}" + + for traveller in Traveller.objects.filter(shuttle=shuttle): + if traveller._is_active(): + shuttle_count += 1 + + over_capacity = traveller_count > bus.seating_capacity or shuttle_count > bus.seating_capacity + if shuttle_count == 0: + shuttle_count = "" + + bus_routes.append({ + 'bus': bus, + 'drivers': drivers, + 'stops': stops, + 'traveller_count': traveller_count, + 'shuttle_name': shuttle_name, + 'shuttle_count': shuttle_count, + 'over_capacity': over_capacity, + }) + return {'routes': bus_routes} + +def emergency_contacts_context(queryset=None): + if queryset is None: + buses = Bus.objects.all() + else: + buses = queryset + + bus_routes = [] + for bus in buses: + drivers = [] + for driver in Driver.objects.filter(bus=bus): + drivers.append(driver) + traveller_list = [] + for travellerRoute in TravellerRoute.objects.filter(busStop__bus=bus): + traveller = travellerRoute.traveller + if not traveller._is_active(): + continue + for family in traveller.get_families(): + parent_a = "" + if family.parent_A_firstname: + parent_a = f"{family.parent_A_firstname} {family.parent_A_lastname} ({family.parent_A_phone})" + parent_b = "" + if family.parent_B_firstname: + parent_b = f"{family.parent_B_firstname} {family.parent_B_lastname} ({family.parent_B_phone})" + contact_a = "" + if family.emergency_contact_A_firstname: + contact_a = f"{family.emergency_contact_A_firstname} {family.emergency_contact_A_lastname} ({family.emergency_contact_A_phone})" + contact_b = "" + if family.emergency_contact_B_firstname: + contact_b = f"{family.emergency_contact_B_firstname} {family.emergency_contact_B_lastname} ({family.emergency_contact_B_phone})" + traveller_list.append({ + 'traveller': traveller, + 'parent_a': parent_a, + 'parent_b': parent_b, + 'contact_a': contact_a, + 'contact_b': contact_b, + 'note': travellerRoute.notes + }) + bus_routes.append({'bus': bus, 'drivers': drivers, 'travellers': traveller_list}) + + return {'routes': bus_routes} \ No newline at end of file diff --git a/busManager/transport/forms.py b/busManager/transport/forms.py new file mode 100644 index 0000000..b807465 --- /dev/null +++ b/busManager/transport/forms.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from django import forms +from django.shortcuts import render + +from common.documents import render_to_pdf +from transport.context_busroll import bus_roll_context + + +class RollDateSelector(forms.Form): + _selected_action = forms.CharField(widget=forms.MultipleHiddenInput) + + +def roll_date_selector(mixin, request, queryset): + if 'generate' in request.POST: + date = request.POST.get("date") + if date: + date = datetime.strptime(date, '%Y-%m-%d') + else: + date = None + return render_to_pdf('reports/bus_roll.html', bus_roll_context(queryset, date=date)) + form = RollDateSelector(initial={'_selected_action': queryset.values_list('id', flat=True)}) + return render(request, 'admin/date_selector.html', context={'form': form}) diff --git a/busManager/transport/models.py b/busManager/transport/models.py index 71a8362..1a4705a 100644 --- a/busManager/transport/models.py +++ b/busManager/transport/models.py @@ -1,3 +1,104 @@ from django.db import models -# Create your models here. +from common.models import Suburb + + +class Company(models.Model): + name = models.CharField(max_length=50, unique=True) + contact_name = models.CharField(max_length=50, blank=True) + contact_number = models.CharField(max_length=15, blank=True) + contact_mobile = models.CharField(max_length=15, blank=True) + contact_email = models.CharField(max_length=50, blank=True) + address = models.CharField(max_length=50, blank=True) + suburb = models.ForeignKey(Suburb, on_delete=models.CASCADE) + notes = models.TextField(blank=True) + + class Meta: + verbose_name_plural = "Companies" + + def __str__(self): + return f"{self.name}" + + +class Bus(models.Model): + company = models.ForeignKey(Company, on_delete=models.CASCADE) + route_name = models.CharField(max_length=50, unique=True) + contract_number = models.CharField(max_length=20, blank=True) + registration = models.CharField(max_length=10, blank=True) + seating_capacity = models.SmallIntegerField() + make = models.CharField(max_length=15, blank=True) + model = models.CharField(max_length=15, blank=True) + notes = models.TextField(blank=True) + + class Meta: + verbose_name_plural = "Buses" + ordering = ["route_name"] + + def __str__(self): + return f"{self.route_name}" + + def traveller_count(self, date=None): + count = 0 + from traveller.models import Traveller + for traveller in Traveller.objects.filter(bus_stops__bus=self): + if traveller._is_active(date): + count += 1 + return count + + +class Shuttle(models.Model): + bus = models.ForeignKey(Bus, on_delete=models.CASCADE) + school = models.ForeignKey("traveller.School", on_delete=models.CASCADE) + custom_name = models.CharField(max_length=10, blank=True) + # transfer_school = models.ForeignKey(School, related_name='transfer_school', on_delete=models.CASCADE) + am_service = models.BooleanField(default=True) + pm_service = models.BooleanField(default=True) + + class Meta: + ordering = ["school__name"] + + def __str__(self): + custom_name = self.custom_name + if custom_name: + custom_name = f" ({self.custom_name})" + else: + custom_name = "" + return f"{self.school.shortName} <-> {self.bus.route_name}{custom_name}" + + def traveller_count(self, date=None): + count = 0 + from traveller.models import Traveller + for traveller in Traveller.objects.filter(shuttle=self): + if traveller._is_active(date): + count += 1 + return count + + +class Driver(models.Model): + bus = models.ForeignKey(Bus, on_delete=models.CASCADE) + first_name = models.CharField(max_length=50) + last_name = models.CharField(max_length=50) + phone_number = models.CharField(max_length=15, blank=True) + + class Meta: + ordering = ["last_name"] + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + +class BusStop(models.Model): + bus = models.ForeignKey(Bus, on_delete=models.CASCADE) + am_time = models.TimeField() + pm_time = models.TimeField() + address = models.CharField(max_length=100) + notes = models.TextField(blank=True) + + class Meta: + ordering = ["bus__route_name", "am_time"] + + def get_stop_number(self): + return BusStop.objects.filter(bus=self.bus, am_time__lt=self.am_time).count() + 1 + + def __str__(self): + return f"{self.bus.route_name} #{self.get_stop_number()} - {self.address}" \ No newline at end of file diff --git a/busManager/transport/views.py b/busManager/transport/views.py index 91ea44a..6d6f00e 100644 --- a/busManager/transport/views.py +++ b/busManager/transport/views.py @@ -1,3 +1,20 @@ +from django.contrib.admin.views.decorators import staff_member_required from django.shortcuts import render -# Create your views here. +from common.documents import render_to_pdf +from transport.context_busroll import bus_roll_context +from transport.context_helpers import bus_summary_context, emergency_contacts_context + + +@staff_member_required +def bus_summary(request): + return render(request, 'reports/bus_summary.html', bus_summary_context()) + +@staff_member_required +def emergency_contacts(request): + return render_to_pdf('reports/emergency_contacts.html', emergency_contacts_context()) + + +@staff_member_required +def bus_roll(request): + return render_to_pdf('reports/bus_roll.html', bus_roll_context()) \ No newline at end of file diff --git a/busManager/traveller/admin.py b/busManager/traveller/admin.py index 8c38f3f..6716c87 100644 --- a/busManager/traveller/admin.py +++ b/busManager/traveller/admin.py @@ -1,3 +1,111 @@ from django.contrib import admin +from django.utils.html import format_html +from rangefilter.filters import DateRangeFilterBuilder -# Register your models here. +from common.admin import MyImportExportModelAdmin +from traveller.adminClone import CloneModelAdmin +from traveller.admin_mixins import SchoolRollMixin, TravellerRollMixin +from traveller.models import Family, TravellerRoute, Traveller, School + + +class FamilyInline(admin.StackedInline): + model = Family + classes = ['collapse'] + extra = 0 + clone_parent = "traveller" + + +class TravellerRouteInline(admin.TabularInline): + model = TravellerRoute + extra = 0 + clone_parent = "traveller" + + +@admin.register(Traveller) +class TravellerAdmin(MyImportExportModelAdmin, CloneModelAdmin, TravellerRollMixin): + list_display = ["first_name", "last_name", "school", "year_level", "is_active", "address", "stop_route", "shuttle", "travel_start_date", "travel_end_date"] + list_filter = [ + "is_active", "school", "year_level", "eligibility_status", "bus_stops__bus", "shuttle", + ("travel_start_date", DateRangeFilterBuilder( + title="Start date" + )), + ("travel_end_date", DateRangeFilterBuilder( + title="End date" + )) + ] + cloneable_fields = ["last_name"] + search_fields = ["first_name", "last_name", "address"] + inlines = [FamilyInline, TravellerRouteInline] + readonly_fields = ["travel_start_date", "travel_end_date", "created_on", "last_edit", "is_active", "address"] + actions = ["export_to_csv", "send_sms", "confirmation_letter", "letter_creator"] + fieldsets = [ + (None, { + 'fields': [ + "is_active", + "school", + "first_name", + "last_name", + "year_level", + "dob", + "address", + ] + }), + ('Office Use', { + 'classes': ('collapse',), + 'fields': [ + "distance_to_school", + "travel_start_date", + "travel_end_date", + "eligibility_status", + "term_1_paid", + "term_2_paid", + "term_3_paid", + "term_4_paid", + "assessment_date", + "application_form_completed", + "parent_notified", + "seat_number", + "created_on", + "last_edit", + ] + }), + (None, {'fields': ["notes", "shuttle"]}) + ] + # list_display_links = None + + def stop_route(self, obj): + from transport.models import BusStop + stops = BusStop.objects.filter(traveller__id__exact=obj.id) + if stops.count() == 0: + return "" + if stops.count() == 1: + return stops.first() + return "Multiple" + + # def save_model(self, request, obj, form, change): + # if obj.is_archived and obj.travel_end_date is None: + # obj.is_archived = False + # super().save_model(request, obj, form, change) + + +# @admin.register(Family) +class FamilyAdmin(MyImportExportModelAdmin, admin.ModelAdmin): + list_display = ["traveller", "__str__", + "parent_A_firstname", "parent_A_lastname", "parent_A_phone", + "parent_B_firstname", "parent_B_lastname", "parent_B_phone", + "emergency_contact_A_firstname", "emergency_contact_A_lastname", "emergency_contact_A_phone", + "emergency_contact_B_firstname", "emergency_contact_B_lastname", "emergency_contact_B_phone"] + + +# @admin.register(TravellerRoute) +class TravellerRouteAdmin(MyImportExportModelAdmin, admin.ModelAdmin): + list_display = ["traveller", "busStop"] + + +@admin.register(School) +class SchoolAdmin(MyImportExportModelAdmin, admin.ModelAdmin, SchoolRollMixin): + list_display = ["__str__", "address", "suburb", "school_email", "phone"] + actions = ["email_travellers_to_school", "show_school_travellers", "export_travellers_to_csv"] + + def school_email(self, obj): + return format_html('{}', obj.email, obj.email) \ No newline at end of file diff --git a/busManager/traveller/adminClone.py b/busManager/traveller/adminClone.py new file mode 100644 index 0000000..cd9759f --- /dev/null +++ b/busManager/traveller/adminClone.py @@ -0,0 +1,161 @@ +from functools import update_wrapper +from django.contrib.admin import ModelAdmin, helpers +from django.contrib.admin.exceptions import DisallowedModelAdminToField +from django.contrib.admin.options import TO_FIELD_VAR +from django.contrib.admin.utils import flatten_fieldsets, unquote +from django.shortcuts import redirect +from django.urls import reverse, path +from django.core.exceptions import PermissionDenied, FieldDoesNotExist + + +class CloneModelAdmin(ModelAdmin): + clone_verbose_name = "Clone" + change_form_template = 'admin/admin_change_form.html' + cloneable_fields = [] + + def get_urls(self): + + # Not certain what this wrap() function is exactly. Just copied it from the django admin get_urls function + def wrap(view): + def wrapper(*args, **kwargs): + return self.admin_site.admin_view(view)(*args, **kwargs) + + wrapper.model_admin = self + return update_wrapper(wrapper, view) + + info = self.opts.app_label, self.opts.model_name + + new_urlpatterns = [ + path( + "/clone/", + wrap(self.clone_view), + name="%s_%s_clone" % info, + ), + ] + + original_urlpatterns = super(CloneModelAdmin, self).get_urls() + + # Important to add custom urls before the existing ones. + # Last entry is /'> which will catch everything not already picked up + return new_urlpatterns + original_urlpatterns + + def change_view(self, request, object_id, form_url='', extra_context=None): + url = reverse("admin:{0}_{1}_clone".format(self.opts.app_label, self.opts.model_name), args=[object_id]) + + extra_context = extra_context or {} + extra_context.update({ + 'clone_verbose_name': self.clone_verbose_name, + 'clone_link': url, + }) + return super(CloneModelAdmin, self).change_view(request, object_id, form_url, extra_context) + + def clone_view(self, request, object_id, form_url='', extra_context=None): + to_field = request.POST.get(TO_FIELD_VAR, request.GET.get(TO_FIELD_VAR)) + if to_field: + print("To field in clone_view. What is this? ",to_field) + if to_field and not self.to_field_allowed(request, to_field): + raise DisallowedModelAdminToField( + "The field %s cannot be referenced." % to_field + ) + + if not self.has_add_permission(request): + raise PermissionDenied + original_obj = self.get_object(request, unquote(object_id), to_field) + if original_obj is None: + return self._get_obj_does_not_exist_redirect(request, self.opts, object_id) + if not self.has_view_or_change_permission(request, original_obj): + raise PermissionDenied + + fieldsets = self.get_fieldsets(request, original_obj) + model_form = self.get_form(request, original_obj, change=False, fields=flatten_fieldsets(fieldsets)) + + if request.method == "POST": + form = model_form(request.POST) + formsets, inline_instances = self._create_formsets( + request, + original_obj, + change=False, + ) + + form_validated = form.is_valid() + if form_validated: + new_object = self.save_form(request, form, change=False) + self.save_model(request, new_object, form, False) + self.clone_related(request, original_obj, new_object) + return redirect(reverse("admin:{0}_{1}_change".format(self.opts.app_label, self.opts.model_name), args=[new_object.pk])) + else: + new_obj = self.model() + for field in self.cloneable_fields: + setattr(new_obj, field, getattr(original_obj, field)) + form = model_form(instance=new_obj) + formsets, inline_instances = self._create_formsets(request, original_obj, change=False) + + admin_form = helpers.AdminForm( + form, + list(self.get_fieldsets(request)), + self.get_prepopulated_fields(request), + self.readonly_fields, + model_admin=self + ) + media = self.media + + inline_formsets = self.get_inline_formsets(request, formsets, inline_instances) + for inline_formset in inline_formsets: + media += inline_formset.media + + title = u'{0} {1}'.format(self.clone_verbose_name, original_obj) + + context = { + **self.admin_site.each_context(request), + "title": title, + "original": title, + "adminform": admin_form, + "is_popup": "_popup" in request.POST or "_popup" in request.GET, + "show_delete": False, + "media": media, + "inline_admin_formsets": inline_formsets, + "errors": helpers.AdminErrorList(form, formsets), + **(extra_context or {}), + } + + context.update(extra_context or {}) + + return self.render_change_form( + request, + context, + form_url=form_url, + change=False, + add=True + ) + + def clone_related(self, request, original_obj, new_obj): + for inline in self.inlines: + if not inline.clone_parent: + continue + try: + inline.model._meta.get_field(inline.clone_parent) + except FieldDoesNotExist: + continue + inline_objects = inline.model.objects.filter(**{inline.clone_parent: original_obj}) + for inline_object in inline_objects: + inline_object.pk = None + setattr(inline_object, inline.clone_parent, new_obj) + inline_object.save() + + +class InlineAdminFormSetFakeOriginal(helpers.InlineAdminFormSet): + + def __iter__(self): + # the template requires the AdminInlineForm to have an `original` + # attribute, which is the model instance, in order to display the + # 'Delete' checkbox + # we don't have `original` because we are just providing initial + # data to the form, so we attach a "fake original" (something that + # evaluates to True) to fool the template and make is display + # the 'Delete' checkbox + # needless to say this is a terrible hack and will break in future + # django versions :) + for inline_form in super(InlineAdminFormSetFakeOriginal, self).__iter__(): + if inline_form.form.initial: + inline_form.original = True + yield inline_form diff --git a/busManager/traveller/admin_mixins.py b/busManager/traveller/admin_mixins.py new file mode 100644 index 0000000..e4e267f --- /dev/null +++ b/busManager/traveller/admin_mixins.py @@ -0,0 +1,60 @@ +import csv +from datetime import date + +from django.http import HttpResponse + +from common.documents import render_to_pdf +from messaging.services.email import email_school_roll +from messaging.services.sms import send_sms +from traveller.context_helpers import school_roll_context, traveller_route_context, confirmation_letter_context, \ + traveller_roll_context +from traveller.models import TravellerRoute + + +class SchoolRollMixin: + + def email_travellers_to_school(self, request, queryset): + return email_school_roll(request, queryset) + + def show_school_travellers(self, request, queryset): + return render_to_pdf('reports/school_roll.html', school_roll_context(queryset)) + + def show_school_travellers_on_date(self, request, queryset): + return render_to_pdf('reports/school_roll.html', school_roll_context(queryset)) + + def export_travellers_to_csv(self, request, queryset): + traveller_list = [] + for school in queryset: + for travellerRoute in TravellerRoute.objects.filter(traveller__school=school): + if not travellerRoute.traveller._is_active(): + continue + traveller_list.append(traveller_route_context(travellerRoute)) + + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f"attachment; filename=traveller_list_{date.today()}.csv" + + writer = csv.DictWriter(response, fieldnames=traveller_list[0].keys()) + writer.writeheader() + writer.writerows(traveller_list) + + return response + + +class TravellerRollMixin: + + def confirmation_letter(self, request, queryset): + return render_to_pdf('mail/confirmation_letter.html', confirmation_letter_context(queryset)) + + def send_sms(self, request, queryset): + return send_sms(self, request, queryset) + + def export_to_csv(self, request, queryset): + traveller_list = traveller_roll_context(queryset) + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f"attachment; filename=traveller_list_{date.today()}.csv" + + writer = csv.DictWriter(response, fieldnames=traveller_list[0].keys()) + writer.writeheader() + writer.writerows(traveller_list) + + return response \ No newline at end of file diff --git a/busManager/traveller/context_helpers.py b/busManager/traveller/context_helpers.py new file mode 100644 index 0000000..107b879 --- /dev/null +++ b/busManager/traveller/context_helpers.py @@ -0,0 +1,90 @@ +from django.db.models import Q + +from transport.models import Bus +from traveller.models import TravellerRoute + + +def school_roll_context(queryset, date=None): + school_list = [] + + for school in queryset: + school_routes = [] + query = Q(traveller__school=school) | Q(traveller__shuttle__school=school) + for bus in Bus.objects.all(): + travellers = [] + for trav_route in TravellerRoute.objects.filter(query).filter(busStop__bus=bus).order_by('busStop__am_time'): + traveller = trav_route.traveller + if not traveller._is_active(date): + continue + bus_stop = trav_route.busStop + + display_name = f"{traveller} ({traveller.get_year_level_display()})" + if traveller.school != school: + display_name = f"{traveller} ({traveller.get_year_level_display()}, {traveller.school.shortName})" + is_fared = "---" + if traveller.eligibility_status == "2": + is_fared = "Y" + shuttle_name = " " + if traveller.shuttle: + shuttle_name = traveller.shuttle.bus + travellers.append({ + 'display': display_name, + 'isFared': is_fared, + 'shuttle': shuttle_name, + 'stop': f"#{bus_stop.get_stop_number()} - {bus_stop.address}", + 'am_time': bus_stop.am_time, + 'pm_time': bus_stop.pm_time + }) + if travellers: + school_routes.append({ + 'bus': bus, + 'travellers': travellers + }) + + school_list.append({"name": school.name, "routes": school_routes}) + return {"schools": school_list} + +def traveller_route_context(traveller_route): + traveller = traveller_route.traveller + bus_stop = traveller_route.busStop + families = traveller.get_families() + address = "" + for family in families: + if address: + address += ";" + address = address + f"{family.residential_address} {family.residential_suburb}" + return { + 'first_name': traveller.first_name, + 'last_name': traveller.last_name, + 'active': traveller.is_active, + 'school': traveller.school, + 'dob': traveller.dob, + 'year_level': traveller.year_level, + 'address': address, + 'start_date': traveller.travel_start_date, + 'end_date': traveller.travel_end_date, + 'eligibility': traveller.get_eligibility_status_display(), + 'shuttle': traveller.shuttle, + 'route': traveller_route.busStop.bus, + 'stop': f"#{bus_stop.get_stop_number()} - {bus_stop.address}", + 'pickup': bus_stop.am_time, + 'drop-off': bus_stop.pm_time + } + +def traveller_roll_context(queryset): + travellers = [] + for traveller in queryset: + for traveller_route in TravellerRoute.objects.filter(traveller=traveller): + travellers.append(traveller_route_context(traveller_route)) + return travellers + +def confirmation_letter_context(queryset): + travellers = [] + for traveller in queryset: + for travellerRoute in TravellerRoute.objects.filter(traveller=traveller): + travellers.append({ + 'traveller': traveller, + 'stop': travellerRoute.busStop, + 'shuttle': traveller.shuttle, + }) + return {'travellers': travellers} \ No newline at end of file diff --git a/busManager/traveller/models.py b/busManager/traveller/models.py index 71a8362..2487719 100644 --- a/busManager/traveller/models.py +++ b/busManager/traveller/models.py @@ -1,3 +1,294 @@ +from datetime import datetime + +import phonenumbers +from django.core.exceptions import ValidationError from django.db import models -# Create your models here. +from common.models import Suburb + + +class School(models.Model): + name = models.CharField(max_length=30, unique=True) + shortName = models.CharField(max_length=10, unique=True) + address = models.CharField(max_length=50) + suburb = models.ForeignKey(Suburb, on_delete=models.CASCADE) + email = models.CharField(max_length=50, blank=True) + phone = models.CharField(max_length=15, blank=True) + principal_name = models.CharField(max_length=50, blank=True) + principal_phone = models.CharField(max_length=15, blank=True) + notes = models.TextField(blank=True) + + def __str__(self): + return self.name + +class Traveller(models.Model): + YEAR = [ + ("PS", "PreSchool"), + ("00", "Year 00"), + ("01", "Year 01"), + ("02", "Year 02"), + ("03", "Year 03"), + ("04", "Year 04"), + ("05", "Year 05"), + ("06", "Year 06"), + ("07", "Year 07"), + ("08", "Year 08"), + ("09", "Year 09"), + ("10", "Year 10"), + ("11", "Year 11"), + ("12", "Year 12"), + ("AL", "Adult Learner"), + ] + + ELIGIBILITY_STATUS = [ + ("1", "Eligible"), + ("2", "Ineligible"), + ("3", "<4.8 Exemption"), + ("4", "Eligible waitlisted"), + ("5", "Ineligible waitlisted"), + ("6", "Kinder Exemption"), + ("7", "Tafe/Post Secondary Exemption"), + ("8", "Other Exemption"), + ] + + school = models.ForeignKey(School, on_delete=models.PROTECT) + first_name = models.CharField(max_length=50) + last_name = models.CharField(max_length=50) + dob = models.DateField(blank=True, null=True) + year_level = models.CharField(max_length=2, choices=YEAR) + bus_stops = models.ManyToManyField("transport.BusStop", through='TravellerRoute', blank=True) + distance_to_school = models.PositiveSmallIntegerField(blank=True, null=True) + address = models.CharField(max_length=100, default="") + + travel_start_date = models.DateField(blank=True, null=True) + travel_end_date = models.DateField(blank=True, null=True) + eligibility_status = models.CharField(max_length=1, choices=ELIGIBILITY_STATUS) + assessment_date = models.DateField(blank=True, null=True) + fee_per_term = models.DecimalField(decimal_places=2, max_digits=5, blank=True, null=True) + term_1_paid = models.BooleanField(default=False) + term_2_paid = models.BooleanField(default=False) + term_3_paid = models.BooleanField(default=False) + term_4_paid = models.BooleanField(default=False) + application_form_completed = models.BooleanField() + parent_notified = models.BooleanField() + seat_number = models.CharField(max_length=5, blank=True) + + created_on = models.DateTimeField(auto_now_add=True, blank=True, null=True) + last_edit = models.DateTimeField(auto_now=True, blank=True, null=True) + is_archived = models.BooleanField(default=False, verbose_name="Archived") + is_active = models.BooleanField(default=False, verbose_name='Active') + notes = models.TextField(blank=True, verbose_name='Admin Notes') + shuttle = models.ForeignKey("transport.Shuttle", on_delete=models.SET_NULL, blank=True, null=True) + + class Meta: + ordering = ["last_name", "first_name"] + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + def save(self, *args, **kwargs): + self._update_active_status() + self._repopulate_address() + super(Traveller, self).save(*args, **kwargs) + + def _is_active(self, date=None): + if date is None: + today = datetime.today() + date = datetime(today.year, today.month, today.day) + + if not self.travel_start_date or datetime(self.travel_start_date.year, self.travel_start_date.month, self.travel_start_date.day) > date: + return False + if not self.travel_end_date: + return True + + end_date = datetime(self.travel_end_date.year, self.travel_end_date.month, self.travel_end_date.day) + return end_date >= date + + def _update_active_status(self): + new_start_date = None + new_end_date = None + + for travellerRoute in TravellerRoute.objects.filter(traveller=self.id): + route_start = travellerRoute.travel_start_date + route_end = travellerRoute.travel_end_date + if route_start is not None: + if new_start_date is None or new_start_date > route_start: + new_start_date = route_start + if route_end is not None: + if new_end_date is None or new_end_date < route_end: + new_end_date = route_end + + self.travel_start_date = new_start_date + self.travel_end_date = new_end_date + self.is_active = self._is_active() + + def fare_paying(self): + if self.eligibility_status != "2": + return + cost_setting = 0 + + if not cost_setting.exists(): + return "TERM_TRAVEL_COST not configured" + + cost = int(cost_setting.get().value) + + stops = 0 + for stop in TravellerRoute.objects.filter(traveller=self.id): + stops += stop.active_stops() + if stops > 1: + stops = 1 + return f"${round(cost*stops)}" + + def _repopulate_address(self): + families = self.get_families() + if families.count() == 0: + self.address = "" + elif families.count() == 1: + family = families.first() + self.address = f"{family.residential_address} {family.residential_suburb}" + else: + self.address = "Multiple" + + def get_families(self): + return Family.objects.filter(traveller__id__exact=self.id) + + +class Family(models.Model): + RELATIONS = [ + ("1", "Parent"), + ("2", "Step-Parent"), + ("3", "Foster Parent"), + ("4", "Host Family"), + ("5", "Sibling"), + ("6", "Grandparent"), + ("7", "Aunt/Uncle"), + ("8", "Cousin"), + ("9", "Carer"), + ("10", "Case Worker"), + ("11", "Friend/Other"), + ] + + traveller = models.ForeignKey(Traveller, on_delete=models.CASCADE) + residential_address = models.CharField(max_length=50, blank=True) + residential_suburb = models.ForeignKey(Suburb, on_delete=models.PROTECT, blank=True, null=True, + related_name='family_residential_suburb') + postal_address = models.CharField(max_length=50, blank=True) + postal_suburb = models.ForeignKey(Suburb, on_delete=models.PROTECT, blank=True, null=True, + related_name='family_postal_suburb') + parent_A_firstname = models.CharField(max_length=50, blank=True) + parent_A_lastname = models.CharField(max_length=50, blank=True) + parent_A_phone = models.CharField(max_length=15, blank=True) + parent_A_email = models.CharField(max_length=50, blank=True) + parent_B_firstname = models.CharField(max_length=50, blank=True) + parent_B_lastname = models.CharField(max_length=50, blank=True) + parent_B_phone = models.CharField(max_length=15, blank=True) + parent_B_email = models.CharField(max_length=50, blank=True) + emergency_contact_A_firstname = models.CharField(max_length=50, blank=True) + emergency_contact_A_lastname = models.CharField(max_length=50, blank=True) + emergency_contact_A_phone = models.CharField(max_length=15, blank=True) + emergency_contact_A_relation = models.CharField(max_length=50, choices=RELATIONS, blank=True) + emergency_contact_B_firstname = models.CharField(max_length=50, blank=True) + emergency_contact_B_lastname = models.CharField(max_length=50, blank=True) + emergency_contact_B_phone = models.CharField(max_length=15, blank=True) + emergency_contact_B_relation = models.CharField(max_length=50, choices=RELATIONS, blank=True) + created_on = models.DateTimeField(auto_now_add=True, blank=True, null=True) + last_edit = models.DateTimeField(auto_now=True, blank=True, null=True) + + class Meta: + verbose_name_plural = "Families" + + def __str__(self): + return self.parent_names() + + def clean(self): + valid_numbers, failed_numbers = self.get_parsed_numbers(True, True) + if len(failed_numbers) > 0: + raise ValidationError(f"Phone number {failed_numbers[0]} not valid") + + def parent_names(self): + a_name = self.parent_A_firstname + b_name = self.parent_B_firstname + if a_name: + if b_name: + return f"{a_name} and {b_name}" + return a_name + elif b_name: + return b_name + return "" + + def get_parsed_numbers(self, parents=False, emergency=False): + numbers = [] + if parents: + if self.parent_A_phone: + numbers.append(self.parent_A_phone) + if self.parent_B_phone: + numbers.append(self.parent_B_phone) + if emergency: + if self.emergency_contact_A_phone: + numbers.append(self.emergency_contact_A_phone) + if self.emergency_contact_B_phone: + numbers.append(self.emergency_contact_B_phone) + valid_numbers = [] + failed_numbers = [] + for number in numbers: + try: + num = phonenumbers.parse(number, "AU") + if phonenumbers.is_valid_number(num): + valid_numbers.append(f"+{num.country_code}{num.national_number}") + else: + failed_numbers.append(number) + except: + failed_numbers.append(number) + + return valid_numbers, failed_numbers + + +class TravellerRoute(models.Model): + traveller = models.ForeignKey(Traveller, on_delete=models.CASCADE) + busStop = models.ForeignKey("transport.BusStop", on_delete=models.CASCADE) + travel_start_date = models.DateField(blank=True, null=True) + travel_end_date = models.DateField(blank=True, null=True) + mon_am = models.BooleanField(default=True) + mon_pm = models.BooleanField(default=True) + tue_am = models.BooleanField(default=True) + tue_pm = models.BooleanField(default=True) + wen_am = models.BooleanField(default=True) + wen_pm = models.BooleanField(default=True) + thu_am = models.BooleanField(default=True) + thu_pm = models.BooleanField(default=True) + fri_am = models.BooleanField(default=True) + fri_pm = models.BooleanField(default=True) + notes = models.TextField(blank=True, verbose_name="Driver Notes") + created_on = models.DateTimeField(auto_now_add=True, blank=True, null=True) + last_edit = models.DateTimeField(auto_now=True, blank=True, null=True) + + def __str__(self): + return f"{self.busStop}" + + def save(self, *args, **kwargs): + super(TravellerRoute, self).save(*args, **kwargs) + self.traveller.save() + + def active_stops(self): + stops = 0 + if self.mon_am: + stops += 1 + if self.mon_pm: + stops += 1 + if self.tue_am: + stops += 1 + if self.tue_pm: + stops += 1 + if self.wen_am: + stops += 1 + if self.wen_pm: + stops += 1 + if self.thu_am: + stops += 1 + if self.thu_pm: + stops += 1 + if self.fri_am: + stops += 1 + if self.fri_pm: + stops += 1 + return stops * 0.1 diff --git a/busManager/traveller/views.py b/busManager/traveller/views.py index 91ea44a..a260a2e 100644 --- a/busManager/traveller/views.py +++ b/busManager/traveller/views.py @@ -1,3 +1,17 @@ +from django.contrib.admin.views.decorators import staff_member_required +from django.http import HttpResponseRedirect from django.shortcuts import render -# Create your views here. +from messaging.services.sms import SMSForm + + +@staff_member_required +def sms_message(request, queryset): + if request.method == 'POST': + form = SMSForm(request.POST) + if form.is_valid(): + HttpResponseRedirect(request.get_full_path()) + else: + form = SMSForm() + + return render(request, 'admin/sms_form.html', context={'form': form, 'travellers': queryset})