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})