24 Commits

Author SHA1 Message Date
a9587776e8 Fixed static and template email files for container 2025-12-09 10:45:25 +01:00
ec7ae4a48e fix message 2025-12-06 19:14:10 +01:00
c93171dbc3 fix endfile spacing 2025-12-06 19:10:46 +01:00
42417927c9 first version of send mail with dummy backend and confirm action 2025-12-06 18:52:03 +01:00
7c7d0e1e62 fake commit 2025-12-06 15:03:11 +01:00
e41eea8527 fix hb inline for admin 2025-12-05 18:07:49 +01:00
369c3b5e19 deploy script 2025-12-05 18:00:27 +01:00
aeb3aa30ce Show Hour building only at ATPL or DL (volo) students 2025-12-05 17:58:51 +01:00
84cf41535c No --- in notes 2025-12-04 15:07:23 +01:00
cdf7e7c677 Show only mission correct for student phase, add only one preference per week 2025-12-04 14:51:52 +01:00
b8f4331d3b Improved xlsx formatting 2025-12-04 13:05:34 +01:00
303359c921 refix merge 2025-12-03 10:27:54 +01:00
ec8373877b fix row merge 2025-12-03 10:21:50 +01:00
e7e47152ed fixed excel formatting 2025-12-03 10:10:00 +01:00
1eb11f33fc fix phone export 2025-12-03 09:23:27 +01:00
e417268991 Merge pull request 'instructor-class' (#3) from instructor-class into flightslot
Reviewed-on: #3
2025-12-02 12:33:41 +01:00
4b5319f557 Instructor import 2025-12-02 12:21:11 +01:00
3ee2269d70 Added instructor availability model and admin 2025-12-02 12:00:15 +01:00
5d1686f24b Added instructor model, admin and basic actions 2025-12-01 18:41:44 +01:00
99a8cfe482 Generalized aircraft assign function 2025-12-01 15:18:30 +01:00
2b1042d3a8 fix mission import 2025-12-01 14:50:17 +01:00
f06f269568 centralized student permission in one function 2025-12-01 14:39:09 +01:00
a31798d0b0 Improved data ordering in requests and addes ac types CAP10 2025-12-01 14:23:46 +01:00
af62bf843c Merge pull request 'airplane-class' (#2) from airplane-class into flightslot
Reviewed-on: #2
2025-11-28 12:12:11 +01:00
40 changed files with 1374 additions and 157 deletions

View File

@@ -31,6 +31,7 @@ ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'admin_confirm',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -43,7 +44,8 @@ INSTALLED_APPS = [
'colorfield',
'import_export',
'django_admin_action_forms',
'polymorphic'
'polymorphic',
"phonenumber_field",
]
# Import Export plugin settings
@@ -142,3 +144,14 @@ STATIC_URL = 'static/'
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
#### Send Email for user password communication ####
# https://docs.djangoproject.com/en/5.2/topics/email/
EMAIL_HOST = "smtp.gmail.com"
EMAIL_HOST_USER = "ema.trabattoni@gmail.com"
EMAIL_HOST_PASSWORD = "okorjsenzptdiwcr"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
EMAIL_FILE_PATH = "/mnt/d/Test/flightslot-mail" # change this to a proper location

View File

@@ -35,6 +35,7 @@ CSRF_TRUSTED_ORIGINS = ["http://localhost:8000", "http://127.0.0.1:8000", "http:
# Application definition
INSTALLED_APPS = [
'admin_confirm',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -47,7 +48,8 @@ INSTALLED_APPS = [
'colorfield',
'import_export',
'django_admin_action_forms',
'polymorphic'
'polymorphic',
"phonenumber_field",
]
# Import Export plugin settings
@@ -73,7 +75,7 @@ ROOT_URLCONF = 'cntmanage.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['/var/www/templates'],
'DIRS': ['/var/www/templates', '/app/templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -137,9 +139,23 @@ USE_TZ = True
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = "static/"
STATIC_ROOT = "/var/www/static/"
STATICFILES_DIRS = [
"/app/static/"
]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
#### Send Email for user password communication ####
# https://docs.djangoproject.com/en/5.2/topics/email/
EMAIL_HOST = "smtp.gmail.com"
EMAIL_HOST_USER = "ema.trabattoni@gmail.com"
EMAIL_HOST_PASSWORD = "okorjsenzptdiwcr"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
# Use dummy backed for testing
EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend"

View File

@@ -2,10 +2,12 @@ from django.contrib import admin
from django.urls import path
from django.shortcuts import redirect
from flightslot.admin import flightslot_user
from flightslot.admin import flightslot_staff
urlpatterns = [
#path('', RedirectView.as_view(url='/admin/', permanent=False)),
path('admin/', admin.site.urls),
path('user/', flightslot_user.urls),
path('staff/', flightslot_staff.urls),
path("", lambda r: redirect("/user/")), # la root porta gli utenti nella pagina giusta
]

5
cntmanage/docker/deploy.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
git pull
./build
docker compose up -d

View File

@@ -19,11 +19,12 @@ FROM python:3.12-slim AS deploy
WORKDIR /app
# Copy application custom static files
RUN mkdir -p static
COPY ./static/cantorair.jpg ./static
COPY ./static/cantorair_blue.jpg ./static
COPY ./static/* ./static
# Copy application custom templates for admin page
RUN mkdir -p /templates/admin
RUN mkdir -p /templates/email
COPY ./templates/admin/* ./templates/admin/
COPY ./templates/email/* ./templates/email/
# Copy and install application wheel package
COPY --from=builder /build/dist/*.whl ./
RUN pip install --no-cache-dir *.whl

View File

@@ -0,0 +1,23 @@
from django.db.models.query import QuerySet, Q
from ..models.instructors import Instructor
from ..models.students import Student
from ..models.missions import MissionProfile
from ..models.aircrafts import Aircraft, AircraftTypes
from typing import List, Dict, Tuple
def assign_aircraft(queryset: QuerySet[Student] | QuerySet[MissionProfile] | QuerySet[Instructor], data: Dict[str, List[AircraftTypes]]) -> Tuple[int, List[str]]:
i: int = 0
ac_types: List[AircraftTypes] = data["aircrafts"]
ac_query: Q = Q() # Build an or query to select all aircrafts of the specified types
for a in ac_types:
ac_query |= Q(type=a)
aircrafts: QuerySet[Aircraft] = Aircraft.objects.filter(ac_query).all() # Execute query
for obj in queryset:
obj.aircrafts.clear()
for ac in aircrafts:
obj.aircrafts.add(ac)
obj.save()
i += 1
return i, [a for a in ac_types]

View File

@@ -0,0 +1,20 @@
from django.db.models.query import QuerySet, Q
from ..models.instructors import Instructor
from ..models.missions import MissionProfile, MissionTypes
from typing import List, Dict, Tuple
def assign_profile(queryset: QuerySet[Instructor], data: Dict[str, List[MissionTypes]]) -> Tuple[int, List[str]]:
i: int = 0
mix_types: List[MissionTypes] = data["mission_profiles"]
mix_query: Q = Q() # Build an or query to select all aircrafts of the specified types
for m in mix_types:
mix_query |= Q(mtype=m)
profiles: QuerySet[MissionProfile] = MissionProfile.objects.filter(mix_query).all() # Execute query
for obj in queryset:
obj.missions.clear()
for ac in profiles:
obj.missions.add(ac)
obj.save()
i += 1
return i, [m for m in mix_types]

View File

@@ -5,6 +5,7 @@ from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from ..models.courses import CourseTypes
from ..models.missions import Training
from ..models.weekpref import WeekPreference
from ..models.hourbuildings import HourBuilding, HourBuildingLegFlight, HourBuildingLegStop, HourBuildingLegBase
@@ -12,6 +13,19 @@ from ..models.hourbuildings import HourBuilding, HourBuildingLegFlight, HourBuil
from datetime import date, datetime
from typing import List
# Enable cell merging for equal mission
MERGE: bool = False
PALETTE : List[str] = [
"#E6F2FF", # azzurro chiarissimo
"#E5FBF8", # verde acqua molto chiaro
"#ECFBE1", # verde chiarissimo
"#FFFBD1", # giallo molto chiaro
"#FFF1D6", # giallo-arancio molto chiaro
"#FFE3DD", # rosa pesca molto chiaro
"#F3E6FA", # lilla chiarissimo
]
def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) -> HttpResponse:
if not queryset.first():
@@ -57,7 +71,7 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
center = Alignment(horizontal="center", vertical="center", wrapText=True)
# Cell styles
border_thick: Side = Side(style='thick', color='000000')
border_thick: Side = Side(style='medium', color='000000')
border_thin: Side = Side(style='thin', color='000000', border_style='dashed')
border_bottom: Border = Border(bottom=border_thick)
border_bottom_thin: Border = Border(bottom=border_thin)
@@ -78,74 +92,78 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
# Each of this iterations fills the table for a student
row: int = 2
row_offset: int = 0
for i, q in enumerate(queryset.order_by("week", "student__surname", "student__name", "student__course"), start=1):
for i, q in enumerate(queryset.order_by("-week", "student__surname", "student__name", "student__course"), start=1):
student_data: List[str]
student_phone: str = q.student.phone if q.student.phone else ""
student_phone: str = str(q.student.phone) if q.student.phone else ""
student_email: str = q.student.email
student_course_type: str
student_course_number: str
student_course_ac: str
student_course_ac: str = f"({'/'.join(t.type for t in q.student.aircrafts.distinct("type").all())})"
if q.student.course:
student_course_type = q.student.course.ctype
student_course_number = str(q.student.course.cnumber)
student_course_ac = " / ".join(t.type for t in q.student.aircrafts.distinct("type").all())
student_data = [
f"{q.student.surname} {q.student.name}\n{student_course_ac}",
"\n".join([f"{q.student.surname} {q.student.name}", student_course_ac]),
f"{student_course_type}-{student_course_number}"
]
else:
student_data = [f"{q.student.surname} {q.student.name}", f"No Course Assigned"]
# Fill Training mission rows
mission_name: str
mission_name: List[str]
mission_days: List[str]
mission_notes: str
mission_data: List[List[str]] = []
for t in Training.objects.filter(weekpref = q.id):
if not t.mission:
raise Exception("No Training Mission Assigned")
mission_name = f"{t.mission.mtype}-{t.mission.mnum}"
mission_notes = t.notes.strip().capitalize() if t.notes else ""
mission_name = [f"{t.mission.mtype}-{t.mission.mnum}"]
if q.student.course and q.student.course.ctype == CourseTypes.PPL: # add course aircraft only for PPL students
mission_name.append(student_course_ac)
mission_name_joined: str = "\n".join(mission_name)
mission_days = [
mission_name if t.monday else "",
mission_name if t.tuesday else "",
mission_name if t.wednesday else "",
mission_name if t.thursday else "",
mission_name if t.friday else "",
mission_name if t.saturday else "",
mission_name if t.sunday else ""
mission_name_joined if t.monday else "",
mission_name_joined if t.tuesday else "",
mission_name_joined if t.wednesday else "",
mission_name_joined if t.thursday else "",
mission_name_joined if t.friday else "",
mission_name_joined if t.saturday else "",
mission_name_joined if t.sunday else ""
]
mission_notes = t.notes if t.notes else "--"
mission_data.append([str(q.week), *student_data, *mission_days, mission_notes, student_phone, student_email, ])
# Fill HourBuilding rows
hb_name: str
hb_name: List[str]
hb_days: List[str]
hb_data: List[List[str]] = []
for h in HourBuilding.objects.filter(weekpref = q.id):
hb_name = f"HB - {h.aircraft}\nVedi Note ->"
hb_days = [
hb_name if h.monday else "",
hb_name if h.tuesday else "",
hb_name if h.wednesday else "",
hb_name if h.thursday else "",
hb_name if h.friday else "",
hb_name if h.saturday else "",
hb_name if h.sunday else ""
]
hb_notes: List[str] = [f"{h.notes}", "---"] if h.notes else []
hb_name = ["HB", f"({h.aircraft})"]
hb_legs_all = HourBuildingLegBase.objects.filter(hb_id = h.id)
for hh in hb_legs_all:
time_str: str = ':'.join(str(hh.time).split(':')[:2]) # keep only hours and minutes
if isinstance(hh, HourBuildingLegFlight):
hb_notes.append(f"{hh.departure} -> {hh.destination} [{time_str}]{f' / PAX: {hh.pax.capitalize()}' if hh.pax else ''}")
hb_pax: str | None = " ".join(x.capitalize() for x in hh.pax.split()) if hh.pax else None
hb_name.append(f"{hh.departure} -> {hh.destination} [{time_str}]{f' / Pax: {hb_pax}' if hb_pax else ''}")
elif isinstance(hh, HourBuildingLegStop):
hb_notes.append(f"STOP [{time_str}] {"Refuel" if hh.refuel else ""}" )
hb_data.append([str(q.week), *student_data, *hb_days, "\n".join(hb_notes), str(q.student.phone), q.student.email])
hb_name.append(f"STOP [{time_str}] {"Refuel" if hh.refuel else ""}" )
hb_name_joined: str = "\n".join(hb_name)
hb_days = [
hb_name_joined if h.monday else "",
hb_name_joined if h.tuesday else "",
hb_name_joined if h.wednesday else "",
hb_name_joined if h.thursday else "",
hb_name_joined if h.friday else "",
hb_name_joined if h.saturday else "",
hb_name_joined if h.sunday else ""
]
hb_notes: str = h.notes.strip().capitalize() if h.notes else ""
hb_data.append([str(q.week), *student_data, *hb_days, hb_notes, str(q.student.phone), q.student.email])
# Build rows for table
all_data: List[List[str]] = mission_data + hb_data
student_start: int = row + row_offset
for row_content in all_data:
for ri, row_content in enumerate(all_data):
for c, cell_content in enumerate(row_content, start=1):
cell = ws.cell(row = row + row_offset, column = c, value = cell_content)
cell.alignment = center
@@ -160,25 +178,25 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
elif c > course_index and c <= note_index:
cell.border = border_bottom_thin + border_right_thin
# Fill mix cells if the cell is not empty
if c > course_index and c < note_index:
if c > course_index and c <= note_index:
if len(cell_content):
cell.fill = PatternFill('solid', fgColor="f0f0f0")
prev_cell_val: str = row_content[0]
merge_start: bool = False
merge_col_start: int = 1
for c, cell_content in enumerate(row_content, start=1):
# Merge cells in the row
if cell_content == prev_cell_val and not merge_start:
merge_start = True
merge_col_start = c-1 # start merge from previous column
elif cell_content != prev_cell_val and merge_start:
merge_start = False
ws.merge_cells(start_row=row+row_offset,
end_row=row+row_offset,
start_column=max(merge_col_start,1),
end_column=max(c-1,1)) # end merge to previous column
prev_cell_val = cell_content
cell.fill = PatternFill('solid', fgColor=PALETTE[ri % len(PALETTE)].lstrip("#").lower())
if MERGE:
prev_cell_val: str = row_content[0]
merge_start: bool = False
merge_col_start: int = 1
for c, cell_content in enumerate(row_content, start=1):
# Merge cells in the row
if cell_content == prev_cell_val and not merge_start:
merge_start = True
merge_col_start = c-1 # start merge from previous column
elif cell_content != prev_cell_val and merge_start:
merge_start = False
ws.merge_cells(start_row=row+row_offset,
end_row=row+row_offset,
start_column=max(merge_col_start,1),
end_column=max(c-1,1)) # end merge to previous column
prev_cell_val = cell_content
# Incement row counter
row_offset += 1
@@ -191,8 +209,7 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
ws.cell(row=student_end, column=c).border = Border(bottom=border_thick, right=border_thin)
# And for last column also a vertical border all student high
if c == mail_index:
for row_content in range(student_start, student_end + 1):
ws.cell(row=row_content, column=c).border += border_right
ws.cell(row=student_end, column=c).border = Border(bottom=border_thick, right=border_thick)
# Merge Week, thick border
ws.cell(row=student_start, column=week_index).border = border_all
@@ -209,6 +226,7 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
# Keep the largest column
max_len: List[int] = []
col_letter: str = "A"
for column_cells in ws.columns:
for cell in column_cells:
cell_lines = str(cell.value).splitlines()
@@ -216,11 +234,9 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
continue
max_len.append(max([len(ll) for ll in cell_lines]))
length: int = max(max_len)
col_letter: str = "A"
if column_cells[0].column:
col_letter = get_column_letter(column_cells[0].column)
ws.column_dimensions[col_letter].width = length + 2
### End of Student Loop ###
# Save document in HttpResponse

View File

@@ -0,0 +1,73 @@
from django.contrib.staticfiles import finders
from django.contrib import messages
from django.core.mail import EmailMultiAlternatives, get_connection
from django.http import HttpRequest
from django.db.models.query import QuerySet
from django.utils.safestring import SafeText
from django.template.loader import render_to_string
from ..models.students import Student
from smtplib import SMTPException
from email.mime.image import MIMEImage
from typing import List
def send_mail_password(request: HttpRequest, queryset: QuerySet[Student]) -> None:
img: MIMEImage | None = None
filename: str
candidates = finders.find("cantorair.png")
if not candidates:
messages.error(request=request, message="Cannot Load CantorAir Logo")
return
elif isinstance(candidates, list):
filename = candidates.pop()
else:
filename = candidates
with open(filename, "rb") as f:
img = MIMEImage(f.read())
img.add_header("Content-ID", "logo_image")
img.add_header("Content-Disposition", "inline", filename="cantorair.png")
# build mail list filling template
mails: List[EmailMultiAlternatives] = []
for student in queryset:
if not student.user or not student.email: # skip student if has not an associated user
continue
try:
username: str = student.user.username
password: str = student.default_password()
address: str = student.email
text_message: str = f"Cantor Air Flight Scheduler\nUsername:{username}\nPassword:{password}\n"
html_message: SafeText = render_to_string(
template_name="email/mail.html",
context={"username": username, "password": password}
)
mail: EmailMultiAlternatives = EmailMultiAlternatives(
subject="CantorAir Flight Scheduler 🛫",
from_email="ema.trabattoni@gmail.com",
body=text_message,
to = [ address ]
)
mail.attach(filename=img)
mail.attach_alternative(content=html_message, mimetype="text/html")
mails.append(mail)
except Exception as e:
messages.error(request=request, message=f"General Error: {e}")
# Open only one conenction and send mass email
try:
with get_connection() as conn:
conn.send_messages(mails)
except SMTPException as e:
messages.error(request=request, message=f"Send Mail SMTP error: {e.strerror}")
except Exception as e:
messages.error(request=request, message=f"Send Mail General error: {e}")
else:
messages.success(request=request, message=f"Successfully sent {len(mails)} messages")
return

View File

@@ -6,22 +6,28 @@ from .models.courses import Course
from .models.students import Student
from .models.missions import MissionProfile
from .models.weekpref import WeekPreference
from .models.instructors import Instructor
from .models.availabilities import Availability
from .admins.aircraft_adm import AircraftAdmin
from .admins.course_adm import CourseAdmin
from .admins.student_adm import StudentAdmin
from .admins.mission_adm import MissionProfileAdmin
from .admins.weekpref_adm import WeekPreferenceAdmin
from .admins.instructor_admin import InstructorAdmin
from .admins.availability_adm import AvailabilityAdmin
from django.contrib.admin import AdminSite
from os import environ
# User website under /user/ URL
##################################
# User website under /user/ URL #
##################################
class FlightSlotUserSite(AdminSite):
site_header = "Flight Scheduler 🛫"
site_title = "Flight Scheduler 🛫"
index_title = "Welcome to CantorAir Flight Scheduler Portal"
index_title = "Welcome to CantorAir Flight Scheduler Student Portal"
def get_app_list(self, request: HttpRequest, *args, **kwargs):
app_list = super().get_app_list(request)
@@ -35,6 +41,27 @@ class FlightSlotUserSite(AdminSite):
flightslot_user = FlightSlotUserSite(name="user_site")
flightslot_user.register(WeekPreference, WeekPreferenceAdmin)
##################################
# User website under /staff/ URL #
##################################
class FlightSlotStaffSite(AdminSite):
site_header = "Flight Scheduler Staff 🛫"
site_title = "Flight Scheduler Staff 🛫"
index_title = "Welcome to CantorAir Flight Scheduler Staff Portal"
def get_app_list(self, request: HttpRequest, *args, **kwargs):
app_list = super().get_app_list(request)
if not request.user.is_superuser:
self.enable_nav_sidebar = False
return app_list
# Register only user visible models
flightslot_staff = FlightSlotUserSite(name="staff_site")
flightslot_staff.register(MissionProfile, MissionProfileAdmin)
flightslot_staff.register(Availability, AvailabilityAdmin)
flightslot_staff.register(Instructor, InstructorAdmin)
# Get version for debug purposes
ver: str = environ.get("VERSION", "dev")
@@ -48,3 +75,5 @@ admin.site.register(Course, CourseAdmin)
admin.site.register(MissionProfile, MissionProfileAdmin)
admin.site.register(Student, StudentAdmin)
admin.site.register(WeekPreference, WeekPreferenceAdmin)
#admin.site.register(Instructor, InstructorAdmin)
#admin.site.register(Availability, AvailabilityAdmin)

View File

@@ -0,0 +1,66 @@
from django import forms
from django.db.models.query import QuerySet
from django.http import HttpRequest
from django.contrib import admin
from django.utils.safestring import SafeText
from ..models.instructors import Instructor
from ..models.availabilities import Availability
from datetime import date
from typing import Any, List
class AvailabilityForm(forms.ModelForm):
model=Availability
class AvailabilityAdmin(admin.ModelAdmin):
model = Availability
list_display = ("week", "instructor__surname", "instructor__name", "days_available", "hours")
list_filter = ("week", )
search_fields = ("instructor__surname","instructor__name", )
#actions = ("export", )
@admin.display(description="Days Available")
def days_available(self, obj: Availability) -> SafeText:
if not obj:
return SafeText("")
days: List[str | None] = [
"Mon" if obj.monday else None,
"Tue" if obj.tuesday else None,
"Wed" if obj.wednesday else None,
"Thu" if obj.thursday else None,
"Fri" if obj.friday else None,
"Sat" if obj.saturday else None,
"Sun" if obj.sunday else None,
]
return SafeText("/".join(d if d else "" for d in days))
def get_queryset(self, request: HttpRequest) -> QuerySet:
return super().get_queryset(request).order_by("-week", "instructor__surname", "instructor__name")
def get_form(self, request: HttpRequest, obj: Availability | None = None, change: bool = False, **kwargs: Any) -> AvailabilityForm:
form: AvailabilityForm = super().get_form(request, obj, change, **kwargs)
if change: # if is only a form change do not set default values and return form
return form
# If form contains the week field
current_week = date.today().isocalendar().week
if "week" in form.base_fields:
# Set default value as current week
form.base_fields["week"].initial = current_week
# If student is current user making request
if hasattr(request.user, "instructor"):
instructor: Instructor = request.user.instructor
if "instructor" in form.base_fields:
form.base_fields["instructor"].initial = instructor
form.base_fields["instructor"].disabled = True
return form
# Imposta automaticamente l'istruttore se non è già valorizzato
def save_model(self, request: HttpRequest, obj: Availability, form: AvailabilityForm, change: bool):
if hasattr(request.user, "instructor") and not obj.instructor_id:
obj.instructor = request.user.instructor
super().save_model(request, obj, form, change)

View File

@@ -1,12 +1,24 @@
from django.contrib import admin
from django.db.models.query import QuerySet
from django.http import HttpRequest
from django.utils.safestring import SafeText
from ..models.students import Student
from ..models.courses import Course
from ..custom.colortag import course_color
class CourseAdmin(admin.ModelAdmin):
list_display = ("ctype", "cnumber","color_display", "year")
list_display = ("ctype", "cnumber","color_display", "course_students", "year")
list_filter = ("ctype", "year")
def get_queryset(self, request: HttpRequest) -> QuerySet:
return super().get_queryset(request).order_by("ctype", "cnumber")
@admin.display(description="Student Number")
def course_students(self, obj: Course) -> SafeText:
if not obj.pk:
return SafeText("")
return SafeText(f"{Student.objects.filter(course = obj.id).count()}")
# Dinamically add color_display property to show a colored dot
@admin.display(description="Color")

View File

@@ -2,15 +2,15 @@ import nested_admin
from django import forms
from django.db import models
from django.forms import TextInput, Textarea
from django.http import HttpRequest
from django.forms import TextInput, Textarea
from durationwidget.widgets import TimeDurationWidget
from ..models.hourbuildings import HourBuilding, HourBuildingLegBase, HourBuildingLegFlight, HourBuildingLegStop
from ..models.weekpref import WeekPreference
from datetime import date
from ..custom.student_permissions import has_edit_permission
class HourBuildingLegFlightForm(forms.ModelForm):
class Meta:
@@ -74,11 +74,7 @@ class HourBuildingInLine(nested_admin.NestedTabularInline):
# If user is a student deny edit permission for week past the current one
def has_change_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
if hasattr(request.user, 'student') and obj:
current_week: int = date.today().isocalendar().week
if current_week > obj.week:
return False
return True
return has_edit_permission(request=request, obj=obj)
def has_delete_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
return self.has_change_permission(request=request, obj=obj)

View File

@@ -0,0 +1,95 @@
from django.forms import TypedMultipleChoiceField
from django.db.models.query import QuerySet
from django.http import HttpRequest
from django.contrib import admin, messages
from django.utils.safestring import SafeText
from django_admin_action_forms import AdminActionFormsMixin, AdminActionForm, action_with_form
from import_export import fields
from import_export.admin import ImportMixin
from import_export.tmp_storages import CacheStorage
from import_export.resources import ModelResource
from ..models.instructors import Instructor
from ..models.aircrafts import AircraftTypes
from ..models.missions import MissionTypes
from ..actions.assign_profile import assign_profile
from ..actions.assign_aircraft import assign_aircraft
from typing import Dict, List
# Resource Class for Instructor data import
class InstructorResource(ModelResource):
surname = fields.Field(attribute="surname", column_name="surname")
name = fields.Field(attribute="name", column_name="name")
email = fields.Field(attribute="email", column_name="email")
phone = fields.Field(attribute="phone", column_name="phone")
# Cleanup fields before entering
def before_import_row(self, row: Dict[str, str], **kwargs) -> None:
row["name"] = SafeText("-".join(c.capitalize() for c in row["name"].split(" ")).strip())
row["surname"] = SafeText("-".join(c.capitalize() for c in row["surname"].split(" ")).strip())
row["phone"] = SafeText(row["phone"].replace(" ",""))
row["email"] = SafeText(row["email"].lower().strip())
return super().before_import_row(row, **kwargs)
class Meta:
model = Instructor
skip_unchanged = True
report_skipped = True
fields = ("surname", "name", "email", "phone", )
import_id_fields = ("surname", "name", )
# Form class to assing aircrafts to instructors
class AssignMissionForm(AdminActionForm):
mission_profiles = TypedMultipleChoiceField(choices=MissionTypes)
# Form class to assing aircrafts to instructors
class AssignAircraftForm(AdminActionForm):
aircrafts = TypedMultipleChoiceField(choices=AircraftTypes)
class InstructorAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
model = Instructor
list_display = ("surname", "name", "email", "phone", "assigned_profiles", "assigned_aircrafts", "active", )
search_fields = ("surname", "name", "phone", "email", )
readonly_fields = ("username", "password", )
actions = ("assign_aircraft", "assign_profile", )
resource_classes = [InstructorResource]
tmp_storage_class = CacheStorage
skip_admin_log = True
@admin.display(description="Password")
def password(self, obj: Instructor) -> SafeText:
return SafeText(obj.default_password())
@admin.display(description="Username")
def username(self, obj: Instructor) -> SafeText:
return SafeText(obj.default_username())
@admin.display(description="Assigned Profiles")
def assigned_profiles(self, obj: Instructor) -> SafeText:
if not obj.aircrafts:
return SafeText("")
return SafeText("/".join(mix.mtype for mix in obj.missions.distinct("mtype").order_by("mtype").all()))
@admin.display(description="Assigned Aircrafts")
def assigned_aircrafts(self, obj: Instructor) -> SafeText:
if not obj.aircrafts:
return SafeText("")
return SafeText("/".join(ac.type for ac in obj.aircrafts.distinct("type").order_by("type").all()))
@action_with_form(AssignAircraftForm, description="Assign Aircraft Type")
def assign_aircraft(self, request: HttpRequest, queryset: QuerySet[Instructor], data: Dict[str, List[AircraftTypes]]):
i: int
ac_types: List[str]
i, ac_types = assign_aircraft(queryset=queryset, data=data)
messages.success(request, f"{i} Instructors updated to {ac_types}")
@action_with_form(AssignMissionForm, description="Assign Mission Type")
def assign_profile(self, request: HttpRequest, queryset: QuerySet[Instructor], data: Dict[str, List[MissionTypes]]):
i: int
mix_types: List[str]
i, mix_types = assign_profile(queryset=queryset, data=data)
messages.success(request, f"{i} Instructors updated to {mix_types}")

View File

@@ -1,7 +1,7 @@
from django.forms import ModelMultipleChoiceField
from django.forms import TypedMultipleChoiceField
from django.contrib import admin, messages
from django.http import HttpRequest
from django.db.models.query import QuerySet, Q
from django.db.models.query import QuerySet
from django.utils.safestring import SafeText
from django_admin_action_forms import AdminActionFormsMixin, AdminActionForm, action_with_form
@@ -11,11 +11,13 @@ from import_export.admin import ImportMixin
from import_export.tmp_storages import CacheStorage
from import_export.resources import ModelResource
from ..models.aircrafts import Aircraft
from ..models.aircrafts import AircraftTypes
from ..models.missions import MissionProfile
from ..actions.assign_aircraft import assign_aircraft
from datetime import timedelta
from typing import Any, Dict
from typing import Any, Dict, List
# Resource Class for Student data import
class MissionProfileResource(ModelResource):
@@ -40,30 +42,25 @@ class MissionProfileResource(ModelResource):
# Form class to assing aircrafts to students
class ChangeAircraftForm(AdminActionForm):
aircrafts = ModelMultipleChoiceField(queryset=Aircraft.objects.distinct('type').all())
aircrafts = TypedMultipleChoiceField(choices=AircraftTypes)
class MissionProfileAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
list_display = ("mtype", "mnum", "assigned_aircrafts", "duration", "notes", )
list_filter = ("mtype", )
actions = ("assign_aircraft", )
resource_classes = [MissionProfileResource]
tmp_storage_class = CacheStorage
skip_admin_log = True
@action_with_form(ChangeAircraftForm, description="Assign Aircraft")
def assign_aircraft(self, request: HttpRequest, queryset: QuerySet[MissionProfile], data: Dict[str, QuerySet[Aircraft]]):
ac_types = [t.type for t in data["aircrafts"]]
ac_query: Q = Q() # Build an or query to select all aircrafts of the specified types
for a in ac_types:
ac_query |= Q(type=a)
aircrafts: QuerySet[Aircraft] = Aircraft.objects.filter(ac_query).all() # Execute query
i: int = 0
for mix in queryset:
mix.aircrafts.clear()
for ac in aircrafts:
mix.aircrafts.add(ac)
mix.save()
i += 1
messages.success(request, f"{i} Students updated to {ac_types}")
def get_queryset(self, request: HttpRequest) -> QuerySet[MissionProfile]:
return super().get_queryset(request).order_by("mtype", "mnum")
@action_with_form(ChangeAircraftForm, description="Assign Aircraft Type")
def assign_aircraft(self, request: HttpRequest, queryset: QuerySet[MissionProfile], data: Dict[str, List[AircraftTypes]]):
i: int
ac_types: List[str]
i, ac_types = assign_aircraft(queryset=queryset, data=data)
messages.success(request, f"{i} Missions updated to {ac_types}")
@admin.display(description="Assigned Aircrafts")
def assigned_aircrafts(self, obj: MissionProfile) -> SafeText:

View File

@@ -1,5 +1,5 @@
from django.forms import ModelChoiceField, TypedMultipleChoiceField, ModelMultipleChoiceField
from django.db.models.query import QuerySet, Q
from django.forms import ModelChoiceField, TypedMultipleChoiceField
from django.db.models.query import QuerySet
from django.http import HttpRequest
from django.contrib import admin, messages
from django.utils.safestring import SafeText
@@ -10,15 +10,19 @@ from import_export.tmp_storages import CacheStorage
from import_export.resources import ModelResource
from import_export.forms import ConfirmImportForm, ImportForm
from django_admin_action_forms import AdminActionFormsMixin, AdminActionForm, action_with_form
from django_admin_action_forms import AdminActionFormsMixin, AdminActionForm, ActionForm, action_with_form
from admin_confirm import AdminConfirmMixin, confirm_action
from ..models.aircrafts import Aircraft, AircraftTypes
from ..models.aircrafts import AircraftTypes
from ..models.courses import Course
from ..models.students import Student
from ..actions.assign_aircraft import assign_aircraft
from ..actions.send_email import send_mail_password
from ..custom.colortag import course_color
from typing import Any, Dict
from typing import Any, Dict, List
# Custom import form to select a course for student input
class StudentCustomConfirmImportForm(ConfirmImportForm):
@@ -56,23 +60,25 @@ class StudentResource(ModelResource):
# Form Class for Student course change
class ChangeCourseForm(AdminActionForm):
course = TypedMultipleChoiceField(choices=AircraftTypes)
course = ModelChoiceField(queryset=Course.objects.all().order_by("ctype", "-cnumber"))
# Form class to assing aircrafts to students
class ChangeAircraftForm(AdminActionForm):
aircrafts = ModelMultipleChoiceField(queryset=Aircraft.objects.distinct('type').all())
aircrafts = TypedMultipleChoiceField(choices=AircraftTypes)
class StudentAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
class StudentAdmin(ImportMixin, AdminConfirmMixin, AdminActionFormsMixin, admin.ModelAdmin):
model = Student
list_display = ("surname", "name", "course", "course_color", "email", "phone", "username", "password", "active", )
list_filter = ("course", "active", )
search_fields = ("surname", "name", "phone", "email", )
actions = ("change_course", "disable_students", "change_aircraft", )
actions = ("change_course", "deactivate_students", "change_aircraft", "send_mail", )
resource_classes = [StudentResource]
confirm_form_class = StudentCustomConfirmImportForm
tmp_storage_class = CacheStorage
skip_admin_log = True
@admin.display(description="Color")
def course_color(self, obj: Student) -> SafeText:
if not obj.course:
@@ -88,7 +94,7 @@ class StudentAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
return SafeText(obj.default_username())
@admin.action(description="Deactivate Students")
def disable_students(self, request: HttpRequest, queryset: QuerySet[Student]):
def deactivate_students(self, request: HttpRequest, queryset: QuerySet[Student]):
for q in queryset.all():
if q.user:
q.user.is_staff = False
@@ -102,22 +108,18 @@ class StudentAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
count: int = queryset.update(course=course)
messages.success(request, f"{count} students updated to {course}")
@action_with_form(ChangeAircraftForm, description="Assign Aircraft")
def change_aircraft(self, request: HttpRequest, queryset: QuerySet[Student], data: Dict[str, QuerySet[Aircraft]]):
ac_types = [t.type for t in data["aircrafts"]]
ac_query: Q = Q() # Build an or query to select all aircrafts of the specified types
for a in ac_types:
ac_query |= Q(type=a)
aircrafts: QuerySet[Aircraft] = Aircraft.objects.filter(ac_query).all() # Execute query
i: int = 0
for student in queryset:
student.aircrafts.clear()
for ac in aircrafts:
student.aircrafts.add(ac)
student.save()
i += 1
@action_with_form(ChangeAircraftForm, description="Assign Aircraft Type")
def change_aircraft(self, request: HttpRequest, queryset: QuerySet[Student], data: Dict[str, List[AircraftTypes]]):
i: int
ac_types: List[str]
i, ac_types = assign_aircraft(queryset=queryset, data=data)
messages.success(request, f"{i} Students updated to {ac_types}")
@confirm_action
@admin.action(description="Send Access Credentials e-mail")
def send_mail(self, request: HttpRequest, queryset: QuerySet[Student], *args: Any) -> None:
send_mail_password(request=request, queryset=queryset)
# Return the initial form for import confirmations, request course to user
def get_confirm_form_initial(self, request: HttpRequest, import_form) -> Dict[str, Any]:
initial: Dict[str, Any] = super().get_confirm_form_initial(request, import_form)

View File

@@ -1,13 +1,17 @@
import nested_admin
from django import forms
from django.db import models
from django.db.models import CharField, TextField
from django.db.models.query_utils import Q
from django.db.models.fields.related import ForeignKey
from django.forms import TextInput, Textarea
from django.http import HttpRequest
from ..models.missions import Training
from ..models.courses import Course, CourseTypes
from ..models.students import Student
from ..models.missions import Training, MissionTypes, MissionProfile
from ..models.weekpref import WeekPreference
from datetime import date
from ..custom.student_permissions import has_edit_permission
class TrainingForm(forms.ModelForm):
model=Training
@@ -21,17 +25,35 @@ class TrainingInLIne(nested_admin.NestedTabularInline):
max_num = 7
formfield_overrides = {
models.CharField: {'widget': TextInput(attrs={'size':'20'})},
models.TextField: {'widget': Textarea(attrs={'rows':4, 'cols':35})},
CharField: {'widget': TextInput(attrs={'size':'20'})},
TextField: {'widget': Textarea(attrs={'rows':4, 'cols':35})},
}
def formfield_for_foreignkey(self, db_field: ForeignKey, request: HttpRequest, **kwargs):
# modify entries for "mission" field, show only types compatible with student course
if not hasattr(request.user, "student") or not hasattr(request.user.student, "course"):
return super().formfield_for_foreignkey(db_field, request, **kwargs)
course: Course = request.user.student.course
if db_field.name == "mission":
match course.ctype:
case CourseTypes.PPL:
kwargs["queryset"] = MissionProfile.objects.filter(mtype=MissionTypes.PPL)
case CourseTypes.ATPL:
q: Q = Q(mtype=MissionTypes.IR) | \
Q(mtype=MissionTypes.MEP) | \
Q(mtype=MissionTypes.MEP_IR) | \
Q(mtype=MissionTypes.CPL) | \
Q(mtype=MissionTypes.CHK)
kwargs["queryset"] = MissionProfile.objects.filter(q).order_by("id")
case CourseTypes.FI:
kwargs["queryset"] = MissionProfile.objects.filter(mtype=MissionTypes.FI).order_by("mnum")
case _:
pass
return super().formfield_for_foreignkey(db_field, request, **kwargs)
# If user is a student deny edit permission for week past the current one
def has_change_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
if hasattr(request.user, 'student') and obj:
current_week: int = date.today().isocalendar().week
if current_week > obj.week:
return False
return True
return has_edit_permission(request=request, obj=obj)
def has_delete_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
return self.has_change_permission(request=request, obj=obj)

View File

@@ -2,12 +2,13 @@ import nested_admin
from django.forms import Form
from django.db.models.query import QuerySet
from django.contrib.auth.models import User
from django.http import HttpRequest, HttpResponse
from django.contrib import admin, messages
from django.utils.translation import ngettext
from django.utils.safestring import SafeText
from ..models.courses import CourseTypes
from ..models.students import Student
from ..models.missions import Training
from ..models.weekpref import WeekPreference
@@ -15,17 +16,17 @@ from .training_adm import TrainingInLIne
from .hourbuilding_adm import HourBuildingInLine
from ..custom.colortag import course_color
from ..custom.student_permissions import has_edit_permission, has_week_add_permission
from ..actions.exportweek import export_selected
from datetime import date
from typing import Dict, List, Any
class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin):
inlines = (TrainingInLIne, HourBuildingInLine, )
list_display = ("week", "student__surname","student__name", "student__course", "course_color", "student_brief_mix",)
list_filter = ("week", "student__course",)
search_fields = ("student__surname","student__name",)
actions = ("export",)
list_display = ("week", "student__surname", "student__name", "student__course", "course_color", "student_brief_mix", )
list_filter = ("week", "student__course", )
search_fields = ("student__surname","student__name", )
actions = ("export", )
@admin.action(description="Export Selected Preferences")
def export(self, request: HttpRequest, queryset: QuerySet[WeekPreference]) -> HttpResponse | None:
@@ -53,6 +54,24 @@ class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin):
return []
return list_filter
# Get available mission or HB depending on student course
def get_inline_instances(self, request: HttpRequest, obj: WeekPreference | None = None):
if hasattr(request.user, "student"):
student: Student = request.user.student
# Only ATPL students are able to book HourBuilding Missions
if student.course and student.course.ctype in (CourseTypes.ATPL, CourseTypes.DISTANCE):
return (
TrainingInLIne(self.model, self.admin_site),
HourBuildingInLine(self.model, self.admin_site),
)
# All other courses have only training
return (TrainingInLIne(self.model, self.admin_site), )
else:
return (
TrainingInLIne(self.model, self.admin_site),
HourBuildingInLine(self.model, self.admin_site),
)
# If a user is registered as student do not show actions
def get_actions(self, request: HttpRequest) -> Dict[str, Any]:
actions = super().get_actions(request)
@@ -62,7 +81,7 @@ class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin):
# If a user is registered as student show only their preferences
def get_queryset(self, request: HttpRequest) -> QuerySet[WeekPreference]:
qs = super().get_queryset(request)
qs = super().get_queryset(request).order_by("-week", "-student__course", "student__surname", "student__name")
if hasattr(request.user, "student"):
return qs.filter(student=request.user.student)
# If admin show everything
@@ -79,7 +98,7 @@ class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin):
# If student is current user making request
if hasattr(request.user, "student"):
student = request.user.student
student: Student = request.user.student
if "student" in form.base_fields:
form.base_fields["student"].initial = student
form.base_fields["student"].disabled = True
@@ -88,33 +107,28 @@ class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin):
# If user is a student deny edit permission for week past the current one
def has_change_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
if hasattr(request.user, "student") and obj:
current_week = date.today().isocalendar().week
if current_week > obj.week:
return False
return True
return has_edit_permission(request=request, obj=obj)
# If user is a student deny edit permission for week past the current one
def has_add_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
return self.has_change_permission(request, obj)
return has_week_add_permission(request=request) and has_edit_permission(request=request, obj=obj)
# If user is a student deny edit permission for week past the current one
def has_delete_permission(self, request: HttpRequest, obj: WeekPreference | None = None)-> bool:
return self.has_change_permission(request, obj)
def changeform_view(self, request: HttpRequest, object_id: int | None = None, form_url: str = "", extra_context=None):
def changeform_view(self, request: HttpRequest, object_id: int | None = None, form_url: str = "", extra_context = None):
extra_context = extra_context or {}
if hasattr(request.user, "student") and object_id:
current_week = date.today().isocalendar().week
weekpref = WeekPreference.objects.get(id=object_id)
if current_week > weekpref.week:
if not has_edit_permission(request=request, obj=weekpref):
extra_context["show_save"] = False
extra_context["show_save_and_continue"] = False
extra_context["show_save_and_add_another"] = False
extra_context["show_delete"] = False
return super().changeform_view(request, object_id, form_url, extra_context)
def save_model(self, request: HttpRequest, obj, form: Form, change: bool):
def save_model(self, request: HttpRequest, obj: WeekPreference, form: Form, change: bool):
# Imposta automaticamente lo studente se non è già valorizzato
if hasattr(request.user, "student") and not obj.student_id:
obj.student = request.user.student

View File

@@ -0,0 +1,21 @@
from django.http import HttpRequest
from ..models.students import Student
from ..models.weekpref import WeekPreference
from datetime import date
def has_week_add_permission(request: HttpRequest):
if hasattr(request.user, 'student'):
student: Student = request.user.student
current_week: int = date.today().isocalendar().week
return student.active and not WeekPreference.objects.filter(student_id=student.id, week=current_week).count()
return True
# allow add, modify, delete depending on a set of requirements
def has_edit_permission(request: HttpRequest, obj: WeekPreference | None = None) -> bool:
if hasattr(request.user, 'student'):
current_week: int = date.today().isocalendar().week
if obj and current_week > obj.week:
return False
return True

View File

@@ -7,8 +7,10 @@ class RedirectNonSuperuserFromAdminMiddleware:
self.get_response = get_response
def __call__(self, request: HttpRequest):
# Se l'utente è loggato, non è superuser e prova ad andare in /admin/...
# Se l'utente è loggato, non è superuser e prova ad andare in /admin/... o qualsiasi altro path
if hasattr(request, "user") and not request.user.is_superuser:
if "/admin/" in request.path:
if hasattr(request.user, "student") and not "/user/" in request.path:
return redirect("/user/") # redirect automatico
elif hasattr(request.user, "instructor") and not "/staff/" in request.path:
return redirect("/staff/") # redirect automatico
return self.get_response(request)

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.8 on 2025-12-01 12:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('flightslot', '0024_alter_missionprofile_mtype'),
]
operations = [
migrations.AlterField(
model_name='aircraft',
name='type',
field=models.CharField(choices=[('C152', 'Cessna 152'), ('P208', 'Tecnam P2008'), ('PA28', 'Piper PA28R'), ('PA34', 'Piper PA34'), ('C182', 'Cessna 182Q'), ('TWEN', 'Tecnam P2010'), ('CP10', 'Cap 10'), ('FSTD', 'Alsim ALX40')], max_length=4),
),
migrations.AlterField(
model_name='hourbuilding',
name='aircraft',
field=models.CharField(choices=[('C152', 'Cessna 152'), ('P208', 'Tecnam P2008'), ('PA28', 'Piper PA28R'), ('PA34', 'Piper PA34'), ('C182', 'Cessna 182Q'), ('TWEN', 'Tecnam P2010'), ('CP10', 'Cap 10'), ('FSTD', 'Alsim ALX40')]),
),
migrations.AlterField(
model_name='weekpreference',
name='week',
field=models.PositiveSmallIntegerField(auto_created=True, db_default=49, db_index=True, verbose_name='Week Number'),
),
]

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2.8 on 2025-12-01 17:27
import django.db.models.deletion
import phonenumber_field.modelfields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('flightslot', '0025_alter_aircraft_type_alter_hourbuilding_aircraft_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='student',
name='email',
field=models.EmailField(db_index=True, max_length=254, unique=True),
),
migrations.AlterField(
model_name='student',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None, unique=True),
),
migrations.CreateModel(
name='Instructor',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('email', models.EmailField(db_index=True, max_length=254)),
('phone', phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None, unique=True)),
('name', models.CharField(max_length=32)),
('surname', models.CharField(max_length=32)),
('active', models.BooleanField(default=True)),
('aircrafts', models.ManyToManyField(to='flightslot.aircraft')),
('missions', models.ManyToManyField(to='flightslot.missionprofile')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.8 on 2025-12-02 10:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('flightslot', '0026_alter_student_email_alter_student_phone_instructor'),
]
operations = [
migrations.AlterField(
model_name='weekpreference',
name='student',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='flightslot.student', verbose_name='Student Selection'),
),
migrations.CreateModel(
name='Availability',
fields=[
('week', models.PositiveSmallIntegerField(auto_created=True, db_default=49, db_index=True, verbose_name='Week Number')),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('monday', models.BooleanField(default=True)),
('tuesday', models.BooleanField(default=True)),
('wednesday', models.BooleanField(default=True)),
('thursday', models.BooleanField(default=True)),
('friday', models.BooleanField(default=True)),
('saturday', models.BooleanField(default=True)),
('sunday', models.BooleanField(default=True)),
('hours', models.DurationField(null=True, verbose_name='Available hours')),
('notes', models.TextField(blank=True, max_length=140, null=True)),
('instructor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='flightslot.instructor', verbose_name='Instructor Selection')),
],
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.8 on 2025-12-02 10:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('flightslot', '0027_alter_weekpreference_student_availability'),
]
operations = [
migrations.AlterModelOptions(
name='availability',
options={'verbose_name': 'Instructor Availability', 'verbose_name_plural': 'Instructor Availabilities'},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-12-05 16:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('flightslot', '0028_alter_availability_options'),
]
operations = [
migrations.AlterField(
model_name='course',
name='ctype',
field=models.CharField(choices=[('FI', 'FI'), ('PPL', 'PPL'), ('ATPL', 'ATPL'), ('DL', 'DISTANCE'), ('DL_VOLO', 'DISTANCE_VOLO'), ('OTHER', 'OTHER')], verbose_name='Course Type'),
),
]

View File

@@ -8,6 +8,7 @@ class AircraftTypes(models.TextChoices):
PA34 = "PA34", _("Piper PA34")
C182 = "C182", _("Cessna 182Q")
P210 = "TWEN", _("Tecnam P2010")
CP10 = "CP10", _("Cap 10")
ALX40 = "FSTD", _("Alsim ALX40")
class Aircraft(models.Model):

View File

@@ -0,0 +1,79 @@
from django.db import models
from datetime import date
from ..models.instructors import Instructor
class Availability(models.Model):
id = models.BigAutoField(
primary_key=True
)
week = models.PositiveSmallIntegerField(
null=False,
db_index=True,
db_default=date.today().isocalendar().week,
auto_created=True,
verbose_name="Week Number"
)
instructor = models.ForeignKey(
Instructor,
null=False,
db_index=True,
on_delete=models.CASCADE,
verbose_name="Instructor Selection"
)
monday = models.BooleanField(
default=True,
null=False
)
tuesday = models.BooleanField(
default=True,
null=False
)
wednesday = models.BooleanField(
default=True,
null=False
)
thursday = models.BooleanField(
default=True,
null=False
)
friday = models.BooleanField(
default=True,
null=False
)
saturday = models.BooleanField(
default=True,
null=False
)
sunday = models.BooleanField(
default=True,
null=False
)
hours = models.DurationField(
null=True,
verbose_name="Available hours"
)
notes = models.TextField(
max_length=140,
null=True,
blank=True
)
class Meta():
verbose_name = "Instructor Availability"
verbose_name_plural = "Instructor Availabilities"
def __str__(self):
return f"Week {self.week} - {self.instructor.surname} {self.instructor.name[0]}."

View File

@@ -8,6 +8,7 @@ class CourseTypes(models.TextChoices):
PPL = "PPL", _("PPL")
ATPL = "ATPL", _("ATPL")
DISTANCE = "DL", _("DISTANCE")
DISTANCE_VOLO = "DL_VOLO", _("DISTANCE_VOLO")
OTHER = "OTHER",_("OTHER")
class Course(models.Model):

View File

@@ -117,6 +117,8 @@ class HourBuildingLegFlight(HourBuildingLegBase):
def save(self, *args, **kwargs):
self.departure = self.departure.upper().strip()
self.destination = self.destination.upper().strip()
if self.pax:
self.pax = " ".join(c.capitalize() for c in self.pax.split())
super().save(*args, **kwargs)
def __str__(self):

View File

@@ -0,0 +1,99 @@
from django.db import models
from django.contrib.auth.models import User, Group
from phonenumber_field import modelfields
from ..models.missions import MissionProfile
from ..models.aircrafts import Aircraft
class Instructor(models.Model):
id = models.AutoField(
primary_key=True
)
email = models.EmailField(
null=False,
db_index=True
)
phone = modelfields.PhoneNumberField(
null=True,
unique=True
)
name = models.CharField(
null=False,
blank=False,
max_length=32
)
surname = models.CharField(
null=False,
blank=False,
max_length=32
)
active = models.BooleanField(
null=False,
default=True
)
aircrafts = models.ManyToManyField(
Aircraft
)
missions = models.ManyToManyField(
MissionProfile
)
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
null=True,
blank=True
)
def default_password(self) -> str: # Maximum 4 digits for passowrd
if self.pk:
return f"{self.name.lower()[0]}{self.surname.lower()}{self.id % 10000}"
else:
return ""
def default_username(self) -> str:
if self.pk and self.user:
return self.user.username
else:
return ""
# Override save method to add user for login upon Student creation
def save(self, *args, **kwargs):
creating: bool = self.pk is None
super().save(*args, **kwargs)
if creating and not self.user:
username: str = f"{self.name.lower()}.{self.surname.lower()}"
# Avoid username conflict with progressive number
base_username = username
counter: int = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
# Create user
user: User = User.objects.create_user(
first_name=self.name.capitalize(),
last_name=self.surname.capitalize(),
username=username,
email=self.email,
password=self.default_password(),
is_staff=True # allows access to admin page
)
instructor_group, _ = Group.objects.get_or_create(name="InstructorGroup")
user.groups.add(instructor_group)
self.user = user
self.save()
def __str__(self):
if self.pk:
return f"{self.surname} {self.name[0]}."
else:
return "New Instructor"

View File

@@ -5,7 +5,7 @@ from datetime import timedelta
from ..models.weekpref import WeekPreference
from ..models.aircrafts import Aircraft
class MissionType(models.TextChoices):
class MissionTypes(models.TextChoices):
OTHER = "OTHER", _("OTHER")
CHK = "CHK", _("CHK_6M")
PPL = "PPL", _("PPL")
@@ -24,8 +24,8 @@ class MissionProfile(models.Model):
mtype = models.CharField(
null=False,
default=MissionType.PPL,
choices=MissionType,
default=MissionTypes.PPL,
choices=MissionTypes,
verbose_name="Mission Type"
)

View File

@@ -1,6 +1,8 @@
from django.db import models
from django.contrib.auth.models import User, Group
from phonenumber_field import modelfields
from ..models.courses import Course
from ..models.aircrafts import Aircraft
@@ -11,12 +13,13 @@ class Student(models.Model):
email = models.EmailField(
null=False,
db_index=True
db_index=True,
unique=True
)
phone = models.CharField(
phone = modelfields.PhoneNumberField(
null=True,
max_length=16
unique=True
)
name = models.CharField(
@@ -52,7 +55,10 @@ class Student(models.Model):
)
def default_password(self) -> str: # Maximum 4 digits for passowrd
return f"{self.name.lower()[0]}{self.surname.lower()}{self.id % 10000}"
if self.pk:
return f"{self.name.lower()[0]}{self.surname.lower()}{self.id % 10000}"
else:
return ""
def default_username(self) -> str:
if self.pk and self.user:
@@ -74,8 +80,8 @@ class Student(models.Model):
counter += 1
# Create user
user: User = User.objects.create_user(
first_name=self.name,
last_name=self.surname,
first_name=self.name.capitalize(),
last_name=self.surname.capitalize(),
username=username,
email=self.email,
password=self.default_password(),

View File

@@ -20,7 +20,7 @@ class WeekPreference(models.Model):
Student,
null=False,
db_index=True,
on_delete=models.DO_NOTHING,
on_delete=models.CASCADE,
verbose_name="Student Selection"
)

49
cntmanage/poetry.lock generated
View File

@@ -66,6 +66,21 @@ files = [
[package.dependencies]
django = ">=3.2"
[[package]]
name = "django-admin-confirm"
version = "1.0.1"
description = "Adds confirmation to Django Admin changes, additions and actions"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "django-admin-confirm-1.0.1.tar.gz", hash = "sha256:fb6a4b7cb9fc6ccd97f92f88275ee8e3912f3ee0ce82da962ad0a2b1b17cd6a0"},
{file = "django_admin_confirm-1.0.1-py3-none-any.whl", hash = "sha256:271a7135e8e5f0cce94a6c06f708dec794d3538ada37e111c1f8d7c8f762b012"},
]
[package.dependencies]
Django = ">=3.2"
[[package]]
name = "django-colorfield"
version = "0.14.0"
@@ -143,6 +158,26 @@ python-monkey-business = ">=1.0.0"
dev = ["Pillow", "black", "dj-database-url", "django-selenosis", "flake8", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "selenium"]
test = ["Pillow", "dj-database-url", "django-selenosis", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "selenium"]
[[package]]
name = "django-phonenumber-field"
version = "8.4.0"
description = "An international phone number field for django models."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "django_phonenumber_field-8.4.0-py3-none-any.whl", hash = "sha256:7a1cb3a6456edb54d879f11ffa0acb227ded08c93b587035d0f28093f0e46511"},
{file = "django_phonenumber_field-8.4.0.tar.gz", hash = "sha256:2b83e843dac35eec6a69880a166487235b737a71a1e38c9a52e5ad67d6996083"},
]
[package.dependencies]
Django = ">=4.2"
phonenumberslite = {version = ">=7.0.2", optional = true, markers = "extra == \"phonenumberslite\""}
[package.extras]
phonenumbers = ["phonenumbers (>=7.0.2)"]
phonenumberslite = ["phonenumberslite (>=7.0.2)"]
[[package]]
name = "django-polymorphic"
version = "4.1.0"
@@ -185,6 +220,18 @@ files = [
[package.dependencies]
et-xmlfile = "*"
[[package]]
name = "phonenumberslite"
version = "9.0.19"
description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "phonenumberslite-9.0.19-py2.py3-none-any.whl", hash = "sha256:92a2426808e7d40b4acf36c97dcc436747807419c5dbc035330df28c13d41c0f"},
{file = "phonenumberslite-9.0.19.tar.gz", hash = "sha256:3794fcec9d2a6510a806187de750853c73ea5dabaac4ecd7fa36e79f869b3c2e"},
]
[[package]]
name = "pillow"
version = "12.0.0"
@@ -436,4 +483,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = "^3.12"
content-hash = "e932d0af75c888d83fecefaaad1d018c508881a3bfde2ea640a82790e3567855"
content-hash = "b45301c627836abac1ef9628e67fc63189b03e7857a7a003854aa1fb30f2a4a3"

View File

@@ -18,6 +18,8 @@ django-colorfield = "^0.14.0"
openpyxl = "^3.1.5"
django-admin-action-forms = "^2.2.1"
django-polymorphic = "^4.1.0"
django-phonenumber-field = {extras = ["phonenumberslite"], version = "^8.4.0"}
django-admin-confirm = "^1.0.1"
[build-system]

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -1 +0,0 @@
admin: CantorAdmin2k25

View File

@@ -8,8 +8,8 @@
<h1 id="site-name">
<a href="{% url 'admin:index' %}" style="color: #0b1728;">
<img src="{% static 'cantorair_blue.jpg' %}"
height="60px"
<img src="{% static 'cantorair.png' %}"
height="70px"
style="margin-right: 20px;"/>
</a>
</h1>

View File

@@ -0,0 +1,408 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 520px) {
.u-row {
width: 500px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 500px !important;
}
}
@media only screen and (max-width: 520px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row {
width: 100% !important;
}
.u-row .u-col {
display: block !important;
width: 100% !important;
min-width: 320px !important;
max-width: 100% !important;
}
.u-row .u-col > div {
margin: 0 auto;
}
}
body {
margin: 0;
padding: 0;
}
table,
td,
tr {
border-collapse: collapse;
vertical-align: top;
}
.ie-container table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors="true"] {
color: inherit !important;
text-decoration: none !important;
}
table,
td {
color: #000000;
}
</style>
</head>
<body
class="clean-body u_body"
style="
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
background-color: #f7f8f9;
color: #000000;
"
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
role="presentation"
id="u_body"
style="
border-collapse: collapse;
table-layout: fixed;
border-spacing: 0;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
vertical-align: top;
min-width: 320px;
margin: 0 auto;
background-color: #f7f8f9;
width: 100%;
"
cellpadding="0"
cellspacing="0"
>
<tbody>
<tr style="vertical-align: top">
<td
style="
word-break: break-word;
border-collapse: collapse !important;
vertical-align: top;
"
>
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #F7F8F9;"><![endif]-->
<div
class="u-row-container"
style="padding: 0px; background-color: transparent"
>
<div
class="u-row"
style="
margin: 0 auto;
min-width: 320px;
max-width: 500px;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
background-color: transparent;
"
>
<div
style="
border-collapse: collapse;
display: table;
width: 100%;
height: 100%;
background-color: transparent;
"
>
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table role="presentation" cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class="u-col u-col-100"
style="
max-width: 320px;
min-width: 500px;
display: table-cell;
vertical-align: top;
"
>
<div
style="
height: 100%;
width: 100% !important;
border-radius: 0px;
-webkit-border-radius: 0px;
-moz-border-radius: 0px;
"
>
<!--[if (!mso)&(!IE)]><!--><div
style="
box-sizing: border-box;
height: 100%;
padding: 0px;
border-top: 0px solid transparent;
border-left: 0px solid transparent;
border-right: 0px solid transparent;
border-bottom: 0px solid transparent;
border-radius: 0px;
-webkit-border-radius: 0px;
-moz-border-radius: 0px;
"
><!--<![endif]-->
<table
style="font-family: arial, helvetica, sans-serif"
role="presentation"
cellpadding="0"
cellspacing="0"
width="100%"
border="0"
>
<tbody>
<tr>
<td
style="
overflow-wrap: break-word;
word-break: break-word;
padding: 10px;
font-family: arial, helvetica, sans-serif;
"
align="left"
>
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
border="0"
>
<tr>
<td
style="
padding-right: 0px;
padding-left: 0px;
"
align="center"
>
<a
href="https://www.cantorair.it"
target="_blank"
style="
color: rgb(0, 0, 238);
text-decoration: underline;
line-height: inherit;
"
><img
align="center"
border="0"
src="cid:logo_image"
alt="Cantor Air Logo"
title=""
style="
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
clear: both;
display: inline-block !important;
border: none;
height: auto;
float: none;
width: 50%;
max-width: 240px;
"
width="240"
/>
</a>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
<table
style="font-family: arial, helvetica, sans-serif"
role="presentation"
cellpadding="0"
cellspacing="0"
width="100%"
border="0"
>
<tbody>
<tr>
<td
style="
overflow-wrap: break-word;
word-break: break-word;
padding: 10px;
font-family: arial, helvetica, sans-serif;
"
align="left"
>
<!--[if mso]><table role="presentation" width="100%"><tr><td><![endif]-->
<h1
style="
margin: 0px;
line-height: 140%;
text-align: center;
word-wrap: break-word;
font-size: 22px;
font-weight: 400;
"
>
<span
>✈️ Welcome to CantorAir Flight Scheduler
✈️</span
>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table
style="font-family: arial, helvetica, sans-serif"
role="presentation"
cellpadding="0"
cellspacing="0"
width="100%"
border="0"
>
<tbody>
<tr>
<td
style="
overflow-wrap: break-word;
word-break: break-word;
padding: 10px;
font-family: arial, helvetica, sans-serif;
"
align="left"
>
<div
style="
font-size: 14px;
line-height: 140%;
text-align: left;
word-wrap: break-word;
"
>
<p
style="
text-align: center;
line-height: inherit;
margin: 0px;
"
>
<span
>You can access the flight scheduler
website with this link: </span
><br /><a href="https://cms.etss.it/user"
><span>https://cms.etss.it</span></a
>
</p>
<p style="text-align: center"><br /></p>
<p dir="ltr" style="text-align: center">
<span style="font-weight: bold"
>Your access credentials:</span
>
</p>
<p dir="ltr" style="text-align: center">
<br /><span>Username: {{username}}</span
><br /><span>Password: {{password}}</span>
</p>
<p dir="ltr" style="text-align: center">
<br />
</p>
<p dir="ltr" style="text-align: center">
<span
>Flight Scheduling is available from </span
><br /><span style="font-weight: bold"
>Monday to Tuesday until 15.00</span
>
</p>
<p style="text-align: center">
<br /><span>_______________________</span>
</p>
<p dir="ltr" style="text-align: center">
<span style="font-style: italic">Team</span
><br /><span style="font-weight: bold"
>CANTOR AIR IT.ATO.0004</span
>
</p>
<p dir="ltr" style="text-align: center">
<span>&nbsp;Email: </span
><a
href="mailto:info@cantorair.it"
target="_blank"
style="line-height: 115%"
><span>info@cantorair.it</span></a
><br /><span>Phone: +39 035 520035</span
><br /><span>_______________________</span>
</p>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

8
note.txt Normal file
View File

@@ -0,0 +1,8 @@
OK leg delle hb all'interno del riquadro per i giorni
OK aereo assegnato allo studente di fianco al numero della missione per PPL, invece per CPL e IR e HB il tipo va di fiaco al nome dello studente
OK le missioni ripetute su piu' giorni hanno una cella per giorno (non unite)
OK lo studente vede solo le missioni della sua fase PPL->PPL ATPL-> tutto
OK ogni richiesta ha un colore diverso che cicla con delle tinte pastello
password: CantorAir2k25