16 Commits

22 changed files with 715 additions and 86 deletions

View File

@@ -31,6 +31,7 @@ ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'admin_confirm',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@@ -143,3 +144,14 @@ STATIC_URL = 'static/'
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 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 # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'admin_confirm',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@@ -74,7 +75,7 @@ ROOT_URLCONF = 'cntmanage.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['/var/www/templates'], 'DIRS': ['/var/www/templates', '/app/templates'],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
@@ -138,9 +139,23 @@ USE_TZ = True
# https://docs.djangoproject.com/en/5.1/howto/static-files/ # https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = "/var/www/static/" STATIC_ROOT = "/var/www/static/"
STATICFILES_DIRS = [
"/app/static/"
]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 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"

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 WORKDIR /app
# Copy application custom static files # Copy application custom static files
RUN mkdir -p static RUN mkdir -p static
COPY ./static/cantorair.jpg ./static COPY ./static/* ./static
COPY ./static/cantorair_blue.jpg ./static
# Copy application custom templates for admin page # Copy application custom templates for admin page
RUN mkdir -p /templates/admin RUN mkdir -p /templates/admin
RUN mkdir -p /templates/email
COPY ./templates/admin/* ./templates/admin/ COPY ./templates/admin/* ./templates/admin/
COPY ./templates/email/* ./templates/email/
# Copy and install application wheel package # Copy and install application wheel package
COPY --from=builder /build/dist/*.whl ./ COPY --from=builder /build/dist/*.whl ./
RUN pip install --no-cache-dir *.whl RUN pip install --no-cache-dir *.whl

View File

@@ -5,6 +5,7 @@ from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
from ..models.courses import CourseTypes
from ..models.missions import Training from ..models.missions import Training
from ..models.weekpref import WeekPreference from ..models.weekpref import WeekPreference
from ..models.hourbuildings import HourBuilding, HourBuildingLegFlight, HourBuildingLegStop, HourBuildingLegBase 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 datetime import date, datetime
from typing import List 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: def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) -> HttpResponse:
if not queryset.first(): if not queryset.first():
@@ -57,7 +71,7 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
center = Alignment(horizontal="center", vertical="center", wrapText=True) center = Alignment(horizontal="center", vertical="center", wrapText=True)
# Cell styles # 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_thin: Side = Side(style='thin', color='000000', border_style='dashed')
border_bottom: Border = Border(bottom=border_thick) border_bottom: Border = Border(bottom=border_thick)
border_bottom_thin: Border = Border(bottom=border_thin) 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 # Each of this iterations fills the table for a student
row: int = 2 row: int = 2
row_offset: int = 0 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_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_email: str = q.student.email
student_course_type: str student_course_type: str
student_course_number: 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: if q.student.course:
student_course_type = q.student.course.ctype student_course_type = q.student.course.ctype
student_course_number = str(q.student.course.cnumber) 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 = [ 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}" f"{student_course_type}-{student_course_number}"
] ]
else: else:
student_data = [f"{q.student.surname} {q.student.name}", f"No Course Assigned"] student_data = [f"{q.student.surname} {q.student.name}", f"No Course Assigned"]
# Fill Training mission rows # Fill Training mission rows
mission_name: str mission_name: List[str]
mission_days: List[str] mission_days: List[str]
mission_notes: str mission_notes: str
mission_data: List[List[str]] = [] mission_data: List[List[str]] = []
for t in Training.objects.filter(weekpref = q.id): for t in Training.objects.filter(weekpref = q.id):
if not t.mission: if not t.mission:
raise Exception("No Training Mission Assigned") 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_days = [
mission_name if t.monday else "", mission_name_joined if t.monday else "",
mission_name if t.tuesday else "", mission_name_joined if t.tuesday else "",
mission_name if t.wednesday else "", mission_name_joined if t.wednesday else "",
mission_name if t.thursday else "", mission_name_joined if t.thursday else "",
mission_name if t.friday else "", mission_name_joined if t.friday else "",
mission_name if t.saturday else "", mission_name_joined if t.saturday else "",
mission_name if t.sunday 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, ]) mission_data.append([str(q.week), *student_data, *mission_days, mission_notes, student_phone, student_email, ])
# Fill HourBuilding rows # Fill HourBuilding rows
hb_name: str hb_name: List[str]
hb_days: List[str] hb_days: List[str]
hb_data: List[List[str]] = [] hb_data: List[List[str]] = []
for h in HourBuilding.objects.filter(weekpref = q.id): for h in HourBuilding.objects.filter(weekpref = q.id):
hb_name = f"HB - {h.aircraft}\nVedi Note ->" hb_name = ["HB", f"({h.aircraft})"]
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_legs_all = HourBuildingLegBase.objects.filter(hb_id = h.id) hb_legs_all = HourBuildingLegBase.objects.filter(hb_id = h.id)
for hh in hb_legs_all: for hh in hb_legs_all:
time_str: str = ':'.join(str(hh.time).split(':')[:2]) # keep only hours and minutes time_str: str = ':'.join(str(hh.time).split(':')[:2]) # keep only hours and minutes
if isinstance(hh, HourBuildingLegFlight): 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): elif isinstance(hh, HourBuildingLegStop):
hb_notes.append(f"STOP [{time_str}] {"Refuel" if hh.refuel else ""}" ) hb_name.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_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 # Build rows for table
all_data: List[List[str]] = mission_data + hb_data all_data: List[List[str]] = mission_data + hb_data
student_start: int = row + row_offset 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): for c, cell_content in enumerate(row_content, start=1):
cell = ws.cell(row = row + row_offset, column = c, value = cell_content) cell = ws.cell(row = row + row_offset, column = c, value = cell_content)
cell.alignment = center cell.alignment = center
@@ -160,25 +178,25 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
elif c > course_index and c <= note_index: elif c > course_index and c <= note_index:
cell.border = border_bottom_thin + border_right_thin cell.border = border_bottom_thin + border_right_thin
# Fill mix cells if the cell is not empty # 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): if len(cell_content):
cell.fill = PatternFill('solid', fgColor="f0f0f0") cell.fill = PatternFill('solid', fgColor=PALETTE[ri % len(PALETTE)].lstrip("#").lower())
if MERGE:
prev_cell_val: str = row_content[0] prev_cell_val: str = row_content[0]
merge_start: bool = False merge_start: bool = False
merge_col_start: int = 1 merge_col_start: int = 1
for c, cell_content in enumerate(row_content, start=1): for c, cell_content in enumerate(row_content, start=1):
# Merge cells in the row # Merge cells in the row
if cell_content == prev_cell_val and not merge_start: if cell_content == prev_cell_val and not merge_start:
merge_start = True merge_start = True
merge_col_start = c-1 # start merge from previous column merge_col_start = c-1 # start merge from previous column
elif cell_content != prev_cell_val and merge_start: elif cell_content != prev_cell_val and merge_start:
merge_start = False merge_start = False
ws.merge_cells(start_row=row+row_offset, ws.merge_cells(start_row=row+row_offset,
end_row=row+row_offset, end_row=row+row_offset,
start_column=max(merge_col_start,1), start_column=max(merge_col_start,1),
end_column=max(c-1,1)) # end merge to previous column end_column=max(c-1,1)) # end merge to previous column
prev_cell_val = cell_content prev_cell_val = cell_content
# Incement row counter # Incement row counter
row_offset += 1 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) 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 # And for last column also a vertical border all student high
if c == mail_index: if c == mail_index:
for row_content in range(student_start, student_end + 1): ws.cell(row=student_end, column=c).border = Border(bottom=border_thick, right=border_thick)
ws.cell(row=row_content, column=c).border += border_right
# Merge Week, thick border # Merge Week, thick border
ws.cell(row=student_start, column=week_index).border = border_all 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 # Keep the largest column
max_len: List[int] = [] max_len: List[int] = []
col_letter: str = "A"
for column_cells in ws.columns: for column_cells in ws.columns:
for cell in column_cells: for cell in column_cells:
cell_lines = str(cell.value).splitlines() cell_lines = str(cell.value).splitlines()
@@ -216,11 +234,9 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
continue continue
max_len.append(max([len(ll) for ll in cell_lines])) max_len.append(max([len(ll) for ll in cell_lines]))
length: int = max(max_len) length: int = max(max_len)
col_letter: str = "A"
if column_cells[0].column: if column_cells[0].column:
col_letter = get_column_letter(column_cells[0].column) col_letter = get_column_letter(column_cells[0].column)
ws.column_dimensions[col_letter].width = length + 2 ws.column_dimensions[col_letter].width = length + 2
### End of Student Loop ### ### End of Student Loop ###
# Save document in HttpResponse # 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

@@ -59,8 +59,8 @@ class FlightSlotStaffSite(AdminSite):
# Register only user visible models # Register only user visible models
flightslot_staff = FlightSlotUserSite(name="staff_site") flightslot_staff = FlightSlotUserSite(name="staff_site")
flightslot_staff.register(Availability, AvailabilityAdmin)
flightslot_staff.register(MissionProfile, MissionProfileAdmin) flightslot_staff.register(MissionProfile, MissionProfileAdmin)
flightslot_staff.register(Availability, AvailabilityAdmin)
flightslot_staff.register(Instructor, InstructorAdmin) flightslot_staff.register(Instructor, InstructorAdmin)
# Get version for debug purposes # Get version for debug purposes
@@ -75,5 +75,5 @@ admin.site.register(Course, CourseAdmin)
admin.site.register(MissionProfile, MissionProfileAdmin) admin.site.register(MissionProfile, MissionProfileAdmin)
admin.site.register(Student, StudentAdmin) admin.site.register(Student, StudentAdmin)
admin.site.register(WeekPreference, WeekPreferenceAdmin) admin.site.register(WeekPreference, WeekPreferenceAdmin)
admin.site.register(Instructor, InstructorAdmin) #admin.site.register(Instructor, InstructorAdmin)
admin.site.register(Availability, AvailabilityAdmin) #admin.site.register(Availability, AvailabilityAdmin)

View File

@@ -2,8 +2,8 @@ import nested_admin
from django import forms from django import forms
from django.db import models from django.db import models
from django.forms import TextInput, Textarea
from django.http import HttpRequest from django.http import HttpRequest
from django.forms import TextInput, Textarea
from durationwidget.widgets import TimeDurationWidget from durationwidget.widgets import TimeDurationWidget

View File

@@ -10,13 +10,15 @@ from import_export.tmp_storages import CacheStorage
from import_export.resources import ModelResource from import_export.resources import ModelResource
from import_export.forms import ConfirmImportForm, ImportForm 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 AircraftTypes from ..models.aircrafts import AircraftTypes
from ..models.courses import Course from ..models.courses import Course
from ..models.students import Student from ..models.students import Student
from ..actions.assign_aircraft import assign_aircraft from ..actions.assign_aircraft import assign_aircraft
from ..actions.send_email import send_mail_password
from ..custom.colortag import course_color from ..custom.colortag import course_color
@@ -64,17 +66,19 @@ class ChangeCourseForm(AdminActionForm):
class ChangeAircraftForm(AdminActionForm): class ChangeAircraftForm(AdminActionForm):
aircrafts = TypedMultipleChoiceField(choices=AircraftTypes) aircrafts = TypedMultipleChoiceField(choices=AircraftTypes)
class StudentAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
class StudentAdmin(ImportMixin, AdminConfirmMixin, AdminActionFormsMixin, admin.ModelAdmin):
model = Student model = Student
list_display = ("surname", "name", "course", "course_color", "email", "phone", "username", "password", "active", ) list_display = ("surname", "name", "course", "course_color", "email", "phone", "username", "password", "active", )
list_filter = ("course", "active", ) list_filter = ("course", "active", )
search_fields = ("surname", "name", "phone", "email", ) search_fields = ("surname", "name", "phone", "email", )
actions = ("change_course", "deactivate_students", "change_aircraft", ) actions = ("change_course", "deactivate_students", "change_aircraft", "send_mail", )
resource_classes = [StudentResource] resource_classes = [StudentResource]
confirm_form_class = StudentCustomConfirmImportForm confirm_form_class = StudentCustomConfirmImportForm
tmp_storage_class = CacheStorage tmp_storage_class = CacheStorage
skip_admin_log = True skip_admin_log = True
@admin.display(description="Color") @admin.display(description="Color")
def course_color(self, obj: Student) -> SafeText: def course_color(self, obj: Student) -> SafeText:
if not obj.course: if not obj.course:
@@ -111,6 +115,11 @@ class StudentAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
i, ac_types = assign_aircraft(queryset=queryset, data=data) i, ac_types = assign_aircraft(queryset=queryset, data=data)
messages.success(request, f"{i} Students updated to {ac_types}") 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 # Return the initial form for import confirmations, request course to user
def get_confirm_form_initial(self, request: HttpRequest, import_form) -> Dict[str, Any]: 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) initial: Dict[str, Any] = super().get_confirm_form_initial(request, import_form)

View File

@@ -1,16 +1,18 @@
import nested_admin import nested_admin
from django import forms 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.forms import TextInput, Textarea
from django.http import HttpRequest 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 ..models.weekpref import WeekPreference
from ..custom.student_permissions import has_edit_permission from ..custom.student_permissions import has_edit_permission
from datetime import date
class TrainingForm(forms.ModelForm): class TrainingForm(forms.ModelForm):
model=Training model=Training
@@ -23,10 +25,32 @@ class TrainingInLIne(nested_admin.NestedTabularInline):
max_num = 7 max_num = 7
formfield_overrides = { formfield_overrides = {
models.CharField: {'widget': TextInput(attrs={'size':'20'})}, CharField: {'widget': TextInput(attrs={'size':'20'})},
models.TextField: {'widget': Textarea(attrs={'rows':4, 'cols':35})}, 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 # 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: def has_change_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
return has_edit_permission(request=request, obj=obj) return has_edit_permission(request=request, obj=obj)

View File

@@ -7,6 +7,7 @@ from django.contrib import admin, messages
from django.utils.translation import ngettext from django.utils.translation import ngettext
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from ..models.courses import CourseTypes
from ..models.students import Student from ..models.students import Student
from ..models.missions import Training from ..models.missions import Training
from ..models.weekpref import WeekPreference from ..models.weekpref import WeekPreference
@@ -15,18 +16,17 @@ from .training_adm import TrainingInLIne
from .hourbuilding_adm import HourBuildingInLine from .hourbuilding_adm import HourBuildingInLine
from ..custom.colortag import course_color from ..custom.colortag import course_color
from ..custom.student_permissions import has_edit_permission from ..custom.student_permissions import has_edit_permission, has_week_add_permission
from ..actions.exportweek import export_selected from ..actions.exportweek import export_selected
from datetime import date from datetime import date
from typing import Dict, List, Any from typing import Dict, List, Any
class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin): class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin):
inlines = (TrainingInLIne, HourBuildingInLine, ) list_display = ("week", "student__surname", "student__name", "student__course", "course_color", "student_brief_mix", )
list_display = ("week", "student__surname","student__name", "student__course", "course_color", "student_brief_mix",) list_filter = ("week", "student__course", )
list_filter = ("week", "student__course",) search_fields = ("student__surname","student__name", )
search_fields = ("student__surname","student__name",) actions = ("export", )
actions = ("export",)
@admin.action(description="Export Selected Preferences") @admin.action(description="Export Selected Preferences")
def export(self, request: HttpRequest, queryset: QuerySet[WeekPreference]) -> HttpResponse | None: def export(self, request: HttpRequest, queryset: QuerySet[WeekPreference]) -> HttpResponse | None:
@@ -54,6 +54,24 @@ class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin):
return [] return []
return list_filter 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 # If a user is registered as student do not show actions
def get_actions(self, request: HttpRequest) -> Dict[str, Any]: def get_actions(self, request: HttpRequest) -> Dict[str, Any]:
actions = super().get_actions(request) actions = super().get_actions(request)
@@ -93,7 +111,7 @@ class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin):
# If user is a student deny edit permission for week past the current one # 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: def has_add_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
return not obj and 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 # 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: def has_delete_permission(self, request: HttpRequest, obj: WeekPreference | None = None)-> bool:
@@ -110,7 +128,7 @@ class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin):
extra_context["show_delete"] = False extra_context["show_delete"] = False
return super().changeform_view(request, object_id, form_url, extra_context) 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 # Imposta automaticamente lo studente se non è già valorizzato
if hasattr(request.user, "student") and not obj.student_id: if hasattr(request.user, "student") and not obj.student_id:
obj.student = request.user.student obj.student = request.user.student

View File

@@ -5,13 +5,17 @@ from ..models.weekpref import WeekPreference
from datetime import date 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 # allow add, modify, delete depending on a set of requirements
def has_edit_permission(request: HttpRequest, obj: WeekPreference | None = None) -> bool: def has_edit_permission(request: HttpRequest, obj: WeekPreference | None = None) -> bool:
if hasattr(request.user, 'student'): if hasattr(request.user, 'student'):
student: Student = request.user.student
if not student.active:
return False
current_week: int = date.today().isocalendar().week current_week: int = date.today().isocalendar().week
if obj and (current_week > obj.week or not student.active): if obj and current_week > obj.week:
return False return False
return True return True

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 CourseTypes(models.TextChoices):
PPL = "PPL", _("PPL") PPL = "PPL", _("PPL")
ATPL = "ATPL", _("ATPL") ATPL = "ATPL", _("ATPL")
DISTANCE = "DL", _("DISTANCE") DISTANCE = "DL", _("DISTANCE")
DISTANCE_VOLO = "DL_VOLO", _("DISTANCE_VOLO")
OTHER = "OTHER",_("OTHER") OTHER = "OTHER",_("OTHER")
class Course(models.Model): class Course(models.Model):

View File

@@ -117,6 +117,8 @@ class HourBuildingLegFlight(HourBuildingLegBase):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.departure = self.departure.upper().strip() self.departure = self.departure.upper().strip()
self.destination = self.destination.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) super().save(*args, **kwargs)
def __str__(self): def __str__(self):

17
cntmanage/poetry.lock generated
View File

@@ -66,6 +66,21 @@ files = [
[package.dependencies] [package.dependencies]
django = ">=3.2" 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]] [[package]]
name = "django-colorfield" name = "django-colorfield"
version = "0.14.0" version = "0.14.0"
@@ -468,4 +483,4 @@ files = [
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "5147211bd07992aff3915544175c8d95d77511b9d42273d17c4452fbef9299eb" content-hash = "b45301c627836abac1ef9628e67fc63189b03e7857a7a003854aa1fb30f2a4a3"

View File

@@ -19,6 +19,7 @@ openpyxl = "^3.1.5"
django-admin-action-forms = "^2.2.1" django-admin-action-forms = "^2.2.1"
django-polymorphic = "^4.1.0" django-polymorphic = "^4.1.0"
django-phonenumber-field = {extras = ["phonenumberslite"], version = "^8.4.0"} django-phonenumber-field = {extras = ["phonenumberslite"], version = "^8.4.0"}
django-admin-confirm = "^1.0.1"
[build-system] [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"> <h1 id="site-name">
<a href="{% url 'admin:index' %}" style="color: #0b1728;"> <a href="{% url 'admin:index' %}" style="color: #0b1728;">
<img src="{% static 'cantorair_blue.jpg' %}" <img src="{% static 'cantorair.png' %}"
height="60px" height="70px"
style="margin-right: 20px;"/> style="margin-right: 20px;"/>
</a> </a>
</h1> </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