polymorphic #1

Merged
Obbart merged 6 commits from polymorphic into flightslot 2025-11-24 12:22:02 +01:00
12 changed files with 280 additions and 96 deletions

View File

@@ -43,6 +43,7 @@ INSTALLED_APPS = [
'colorfield',
'import_export',
'django_admin_action_forms',
'polymorphic'
]
# Import Export plugin settings
@@ -52,7 +53,7 @@ IMPORT_EXPORT_SKIP_ADMIN_LOG = True
IMPORT_FORMATS = [CSV]
MIDDLEWARE = [
'flightslot.middleware.RedirectNonSuperuserFromAdminMiddleware',
'flightslot.middleware.RedirectNonSuperuserFromAdminMiddleware', # custom middleware to show "user" page to non superuser
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',

View File

@@ -9,7 +9,3 @@ urlpatterns = [
path('user/', flightslot_user.urls),
path("", lambda r: redirect("/user/")), # la root porta gli utenti nella pagina giusta
]
admin.site.site_header = "Flight Scheduler 🛫"
admin.site.site_title = "Flight Scheduler 🛫"
admin.site.index_title = "Welcome to CantorAir Flight Scheduler Portal"

View File

@@ -10,7 +10,7 @@ from typing import List
from ..models.weekpref import WeekPreference
from ..models.missions import Training
from ..models.hourbuildings import HourBuilding,HourBuildingLeg
from ..models.hourbuildings import HourBuilding, HourBuildingLegFlight, HourBuildingLegStop, HourBuildingLegBase
def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) -> HttpResponse:
@@ -41,7 +41,7 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
# Header titles
days = [f"{datetime.strptime(f"{year} {week} {x}", "%G %V %u").strftime("%A")} {datetime.strptime(f"{year} {week} {x}", "%G %V %u").day}" for x in range(1,8)]
headers = ["Week", "Student", "Course", *days, "Cell.", "Mail", "Notes"]
headers = ["Week", "Student", "Course", *days, "Notes", "Cell.", "Mail"]
# Header fields positions
week_index: int = headers.index("Week") + 1
@@ -59,7 +59,12 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
# Cell styles
border_thick: Side = Side(style='thick', 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)
border_left: Border = Border(left=border_thick)
border_right: Border = Border(right=border_thick)
border_right_thin: Border = Border(right=border_thin)
border_all: Border = Border(bottom=border_thick, top=border_thick, left=border_thick, right=None)
# Scrittura header
@@ -102,15 +107,14 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
mission_name if t.sunday else ""
]
mission_notes = t.notes if t.notes else "--"
mission_data.append([str(q.week), *student_data, *mission_days, student_phone, student_email, mission_notes])
mission_data.append([str(q.week), *student_data, *mission_days, mission_notes, student_phone, student_email, ])
# Fill HourBuilding rows
hb_name: str
hb_days: List[str]
hb_notes: str
hb_data: List[List[str]] = []
for h in HourBuilding.objects.filter(weekpref = q.id):
hb_name = f"HB-{h.aircraft}\nVedi Note ->"
hb_name = f"HB - {h.aircraft}\nVedi Note ->"
hb_days = [
hb_name if h.monday else "",
hb_name if h.tuesday else "",
@@ -120,53 +124,71 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
hb_name if h.saturday else "",
hb_name if h.sunday else ""
]
hb_notes = f"{h.notes}\n----\n" if h.notes else ""
hb_legs = HourBuildingLeg.objects.filter(hb_id = h.id)
for hh in hb_legs:
hb_notes += f"{hh.departure} -> {hh.destination} [{hh.time}]\n" if not hh.stop else f"STOP at {hh.departure} [{hh.time}]\n"
hb_notes.strip('\n')
hb_data.append([str(q.week), *student_data, *hb_days, str(q.student.phone), q.student.email, hb_notes])
hb_notes: List[str] = [f"{h.notes}", "---"] if h.notes else []
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 ''}")
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])
# Build rows for table
all_data: List[List[str]] = mission_data + hb_data
student_start: int = row + row_offset
for r in all_data:
for j, c in enumerate(r, start=1):
cell = ws.cell(row = row + row_offset, column = j, value = c)
for row_content in 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
# Format Student Name
if j == student_index:
if c == student_index:
cell.font = bold_black
# Format Course Column
if j == course_index and q.student.course:
# Format Course Column with color
elif c == course_index and q.student.course:
cell.font = bold_black
cell.fill = PatternFill("solid", fgColor=str(q.student.course.color).lstrip('#').lower())
prev_cell_val: str = r[0]
# Add internal borders between mix cells and notes
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 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, v in enumerate(r, start=1):
for c, cell_content in enumerate(row_content, start=1):
# Merge cells in the row
if v == prev_cell_val and not merge_start:
if cell_content == prev_cell_val and not merge_start:
merge_start = True
merge_col_start = c-1 # start merge from previous column
elif v != prev_cell_val and merge_start:
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 = v
prev_cell_val = cell_content
# Incement row counter
row_offset += 1
# End week preferences for this student
student_end: int = row + row_offset -1
student_end: int = row + row_offset - 1
# Add thick border to the last cell row of this student
for c in range(course_index, mail_index + 1):
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
# Merge Week, thick border
# ws.cell(row=student_start, column=week_index).border = border_all
# ws.merge_cells(start_row=student_start, end_row=student_end, start_column=week_index, end_column=week_index)
ws.cell(row=student_start, column=week_index).border = border_all
ws.merge_cells(start_row=student_start, end_row=student_end, start_column=week_index, end_column=week_index)
# Merge Name, thick border
ws.cell(row=student_start, column=student_index).border = border_all
ws.merge_cells(start_row=student_start, end_row=student_end, start_column=student_index, end_column=student_index)
@@ -177,13 +199,15 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
# Merge Mail
ws.merge_cells(start_row=student_start, end_row=student_end, start_column=mail_index, end_column=mail_index)
# Add thick border to the last cell row of this student
for i in range(course_index, len(all_data[0])+1):
ws.cell(row=student_end, column=i).border = border_bottom
# Keep the largest column
max_len: List[int] = []
for column_cells in ws.columns:
length: int = max(len(str(cell.value)) for cell in column_cells)
for cell in column_cells:
cell_lines = str(cell.value).splitlines()
if len(cell_lines) == 0:
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)
@@ -191,8 +215,7 @@ def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) ->
### End of Student Loop ###
# Save document in HttpResponse
wb.save(response)
return response
return response

View File

@@ -10,6 +10,7 @@ from .admins.course_adm import CourseAdmin
from .admins.student_adm import StudentAdmin
from .admins.mission_adm import MissionProfileAdmin
from .admins.weekpred_adm import WeekPreferenceAdmin
#from .admins.hourbuilding_adm import HourBuilding, HourBuildingInLine
from django.contrib.admin import AdminSite
@@ -31,6 +32,10 @@ flightslot_user = FlightSlotUserSite(name="user_site")
# registra SOLO i modelli autorizzati
flightslot_user.register(WeekPreference, WeekPreferenceAdmin)
admin.site.site_header = "Flight Scheduler Admin 🛫"
admin.site.site_title = "Flight Scheduler Admin 🛫"
admin.site.index_title = "Welcome to CantorAir Flight Scheduler Administrator Portal"
admin.site.register(Course, CourseAdmin)
admin.site.register(MissionProfile, MissionProfileAdmin)
admin.site.register(Student, StudentAdmin)

View File

@@ -7,36 +7,63 @@ from django.http import HttpRequest
from durationwidget.widgets import TimeDurationWidget
from ..models.hourbuildings import HourBuilding, HourBuildingLeg
from ..models.hourbuildings import HourBuilding, HourBuildingLegBase, HourBuildingLegFlight, HourBuildingLegStop
from ..models.weekpref import WeekPreference
from datetime import date
class HourBuildingLegForm(forms.ModelForm):
class HourBuildingLegFlightForm(forms.ModelForm):
class Meta:
model = HourBuildingLeg
fields = '__all__'
model = HourBuildingLegFlight
fields = "__all__"
widgets = {
'time': TimeDurationWidget(show_days=False,
show_seconds=False
"time": TimeDurationWidget(show_days=False,
show_seconds=False,
attrs={
"style": (
"margin-right:5px; margin-left:5px; width:40px; min:0; max:5"
)
})
}
class HourBuildingLegStopForm(forms.ModelForm):
class Meta:
model = HourBuildingLegStop
fields = "__all__"
widgets = {
"time": TimeDurationWidget(show_days=False,
show_seconds=False,
attrs={
"style": (
"margin-right:5px; margin-left:5px; width:40px;"
)
})
}
# Register your models here.
class HourBuildingLegInline(nested_admin.NestedTabularInline):
model = HourBuildingLeg
form = HourBuildingLegForm
extra = 0
fk_name = 'hb'
max_num = 5
formfield_overrides = {
models.CharField: {'widget': TextInput(attrs={'size':'20'})},
models.TextField: {'widget': Textarea(attrs={'rows':4, 'cols':35})},
}
class HourBuildingLegBaseInLine(nested_admin.NestedStackedPolymorphicInline):
model = HourBuildingLegBase
fk_name = "hb"
verbose_name_plural = "Hour Building Legs"
class HourBuildingLegFlightInLine(nested_admin.NestedStackedPolymorphicInline.Child):
model = HourBuildingLegFlight
form = HourBuildingLegFlightForm
fk_name = "hourbuildinglegbase_ptr"
fields = ("departure", "time", "destination", "pax", )
hide_title = True
class HourBuildingLegStopInLine(nested_admin.NestedStackedPolymorphicInline.Child):
model = HourBuildingLegStop
form = HourBuildingLegFlightForm
fk_name = "hourbuildinglegbase_ptr"
fields = ("time", "refuel", )
child_inlines = (HourBuildingLegFlightInLine, HourBuildingLegStopInLine, )
# If user is a student deny edit permission for week past the current one
def has_change_permission(self, request: HttpRequest, obj: HourBuilding | None = None):
if hasattr(request.user, 'student') and obj:
if hasattr(request.user, "student") and obj:
current_week = date.today().isocalendar().week
if not obj.DoesNotExist and current_week > obj.weekpref.week:
return False
@@ -45,26 +72,16 @@ class HourBuildingLegInline(nested_admin.NestedTabularInline):
def has_delete_permission(self, request: HttpRequest, obj: HourBuilding | None = None):
return self.has_change_permission(request=request, obj=obj)
class HourBuildingInLine(nested_admin.NestedTabularInline):
model = HourBuilding
inlines = (HourBuildingLegBaseInLine,)
extra = 0
inlines = [HourBuildingLegInline]
fk_name = 'weekpref'
verbose_name_plural = "Hour Building"
max_num = 7
fk_name = "weekpref"
verbose_name_plural = "Hour Buildings"
formfield_overrides = {
models.CharField: {'widget': TextInput(attrs={'size':'20'})},
models.TextField: {'widget': Textarea(attrs={'rows':4, 'cols':35})},
models.CharField: {"widget": TextInput(attrs={"size":"20"})},
models.TextField: {"widget": Textarea(attrs={"rows":4, "cols":35})},
}
# 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):
if hasattr(request.user, 'student') and obj:
current_week = date.today().isocalendar().week
if current_week > obj.week:
return False
return True
def has_delete_permission(self, request: HttpRequest, obj: WeekPreference | None = None):
return self.has_change_permission(request=request, obj=obj)

View File

@@ -18,8 +18,8 @@ from ..actions.exportweek import export_selected
from datetime import date
class WeekPreferenceAdmin(nested_admin.NestedModelAdmin):
inlines = (TrainingInLIne, HourBuildingInLine,)
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", "student",)
actions = ("export",)

View File

@@ -0,0 +1,58 @@
# Generated by Django 5.2.8 on 2025-11-21 11:20
import datetime
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('flightslot', '0017_alter_missionprofile_mtype_alter_weekpreference_week'),
]
operations = [
migrations.CreateModel(
name='HourBuildingLegBase',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('time', models.DurationField(default=datetime.timedelta(seconds=3600))),
('hb', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='flightslot.hourbuilding')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='HourBuildingLegFlight',
fields=[
('hourbuildinglegbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='flightslot.hourbuildinglegbase')),
('departure', models.CharField(default='LILV', max_length=4)),
('destination', models.CharField(default='LILV', max_length=4)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('flightslot.hourbuildinglegbase',),
),
migrations.CreateModel(
name='HourBuildingLegStop',
fields=[
('hourbuildinglegbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='flightslot.hourbuildinglegbase')),
('location', models.CharField(default='LILV', max_length=4)),
('refuel', models.BooleanField(default=False)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('flightslot.hourbuildinglegbase',),
),
migrations.DeleteModel(
name='HourBuildingLeg',
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.2.8 on 2025-11-21 16:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('flightslot', '0018_hourbuildinglegbase_hourbuildinglegflight_and_more'),
]
operations = [
migrations.RemoveField(
model_name='hourbuildinglegstop',
name='location',
),
migrations.AddField(
model_name='hourbuildinglegflight',
name='pax',
field=models.CharField(max_length=16, null=True),
),
migrations.AlterField(
model_name='hourbuildinglegbase',
name='time',
field=models.DurationField(),
),
migrations.AlterField(
model_name='hourbuildinglegflight',
name='departure',
field=models.CharField(max_length=4),
),
migrations.AlterField(
model_name='hourbuildinglegflight',
name='destination',
field=models.CharField(max_length=4),
),
]

View File

@@ -1,11 +1,11 @@
from django.utils.translation import gettext_lazy as _
from django.db import models
from datetime import timedelta
from polymorphic.models import PolymorphicModel
from ..models.weekpref import WeekPreference
from ..models.aircrafts import AircraftTypes
class HourBuilding(models.Model):
id = models.BigAutoField(
primary_key=True
@@ -66,7 +66,7 @@ class HourBuilding(models.Model):
def __str__(self):
return f"Hour Building: {self.aircraft}"
class HourBuildingLeg(models.Model):
class HourBuildingLegBase(PolymorphicModel):
id = models.BigAutoField(
primary_key=True
)
@@ -76,32 +76,63 @@ class HourBuildingLeg(models.Model):
on_delete=models.CASCADE
)
time = models.DurationField(
null=False,
blank=False
)
# Change displayed name in the inline form
class Meta(PolymorphicModel.Meta):
verbose_name = "Flight Leg or Stop"
verbose_name_plural = "Flight Legs or Stops"
def __str__(self):
return f"Hour Building Leg"
class HourBuildingLegFlight(HourBuildingLegBase):
departure = models.CharField(
null=False,
blank=False,
default="LILV",
max_length=4
)
destination = models.CharField(
null=False,
blank=False,
default="LILV",
max_length=4
)
time = models.DurationField(
null=False,
default = timedelta(hours=1)
pax = models.CharField(
null=True,
blank=True,
max_length=16,
verbose_name="Pax (optional)"
)
stop = models.BooleanField(
# Change displayed name in the inline form
class Meta(HourBuildingLegBase.Meta):
verbose_name = "Flight leg"
verbose_name_plural = "Flight legs"
def save(self, *args, **kwargs):
self.departure = self.departure.upper().strip()
self.destination = self.destination.upper().strip()
super().save(*args, **kwargs)
def __str__(self):
return f"{self.departure} -> {self.destination}"
class HourBuildingLegStop(HourBuildingLegBase):
refuel = models.BooleanField(
default=False
)
# Change displayed name in the inline form
class Meta(HourBuildingLegBase.Meta):
verbose_name = "Stop"
verbose_name_plural = "Stops"
def __str__(self):
if self.stop:
return "Refuelling Stop"
else:
return f"Flight Leg: {self.departure} -> {self.destination}"
return f"Refuel" if self.refuel else f"No Refuel"

View File

@@ -51,24 +51,24 @@ class Student(models.Model):
# Override save method to add user for login upon Student creation
def save(self, *args, **kwargs):
creating = self.pk is None
creating: bool = self.pk is None
super().save(*args, **kwargs)
if creating and not self.user:
username = f"{self.name.lower()}.{self.surname.lower()}"
username: str = f"{self.name.lower()}.{self.surname.lower()}"
# Avoid username conflict with progressive number
base_username = username
counter = 1
counter: int = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
# Create user
user = User.objects.create_user(
user: User = User.objects.create_user(
first_name=self.name,
last_name=self.surname,
username=username,
email=self.email,
password=self.default_password(),
is_staff=True
is_staff=True # allows access to admin page
)
student_group, _ = Group.objects.get_or_create(name="StudentGroup")

23
cntmanage/poetry.lock generated
View File

@@ -2,14 +2,14 @@
[[package]]
name = "asgiref"
version = "3.10.0"
version = "3.11.0"
description = "ASGI specs, helper code, and adapters"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734"},
{file = "asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e"},
{file = "asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"},
{file = "asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4"},
]
[package.extras]
@@ -143,6 +143,21 @@ 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-polymorphic"
version = "4.1.0"
description = "Seamless polymorphic inheritance for Django models"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "django_polymorphic-4.1.0-py3-none-any.whl", hash = "sha256:0ce3984999e103a0d1a434a5c5617f2c7f990dc3d5fb3585ce0fadadf9ff90ea"},
{file = "django_polymorphic-4.1.0.tar.gz", hash = "sha256:4438d95a0aef6c4307cd6c83ead387e1142ce80b65188a931ec2f0dbdd9bfc51"},
]
[package.dependencies]
Django = ">=3.2"
[[package]]
name = "et-xmlfile"
version = "2.0.0"
@@ -421,4 +436,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = "^3.12"
content-hash = "6bf43236f441d8b6bf8d1928910d169d3b29cfa499bb7d09d97ea227f8115658"
content-hash = "e932d0af75c888d83fecefaaad1d018c508881a3bfde2ea640a82790e3567855"

View File

@@ -17,6 +17,7 @@ django-import-export = "^4.3.13"
django-colorfield = "^0.14.0"
openpyxl = "^3.1.5"
django-admin-action-forms = "^2.2.1"
django-polymorphic = "^4.1.0"
[build-system]