diff --git a/cntmanage/cntmanage/settings.py b/cntmanage/cntmanage/settings.py index 2f6c74d..15c1c07 100644 --- a/cntmanage/cntmanage/settings.py +++ b/cntmanage/cntmanage/settings.py @@ -43,7 +43,8 @@ INSTALLED_APPS = [ 'colorfield', 'import_export', 'django_admin_action_forms', - 'polymorphic' + 'polymorphic', + "phonenumber_field", ] # Import Export plugin settings diff --git a/cntmanage/cntmanage/settings_prod.py b/cntmanage/cntmanage/settings_prod.py index 74b066c..a1f4808 100644 --- a/cntmanage/cntmanage/settings_prod.py +++ b/cntmanage/cntmanage/settings_prod.py @@ -47,7 +47,8 @@ INSTALLED_APPS = [ 'colorfield', 'import_export', 'django_admin_action_forms', - 'polymorphic' + 'polymorphic', + "phonenumber_field", ] # Import Export plugin settings diff --git a/cntmanage/flightslot/actions/assign_aircraft.py b/cntmanage/flightslot/actions/assign_aircraft.py index 1e3a624..614ca2e 100644 --- a/cntmanage/flightslot/actions/assign_aircraft.py +++ b/cntmanage/flightslot/actions/assign_aircraft.py @@ -1,12 +1,13 @@ 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], data: Dict[str, List[AircraftTypes]]) -> Tuple[int, List[str]]: +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 diff --git a/cntmanage/flightslot/actions/assign_profile.py b/cntmanage/flightslot/actions/assign_profile.py new file mode 100644 index 0000000..87c6cfb --- /dev/null +++ b/cntmanage/flightslot/actions/assign_profile.py @@ -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] diff --git a/cntmanage/flightslot/admin.py b/cntmanage/flightslot/admin.py index af1f613..4d3cb92 100644 --- a/cntmanage/flightslot/admin.py +++ b/cntmanage/flightslot/admin.py @@ -6,12 +6,14 @@ 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 .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 django.contrib.admin import AdminSite @@ -48,3 +50,4 @@ 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) diff --git a/cntmanage/flightslot/admins/instructor_admin.py b/cntmanage/flightslot/admins/instructor_admin.py new file mode 100644 index 0000000..154dabf --- /dev/null +++ b/cntmanage/flightslot/admins/instructor_admin.py @@ -0,0 +1,65 @@ +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 ..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 + +# 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(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", ) + + @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}") diff --git a/cntmanage/flightslot/migrations/0026_alter_student_email_alter_student_phone_instructor.py b/cntmanage/flightslot/migrations/0026_alter_student_email_alter_student_phone_instructor.py new file mode 100644 index 0000000..7c90c46 --- /dev/null +++ b/cntmanage/flightslot/migrations/0026_alter_student_email_alter_student_phone_instructor.py @@ -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)), + ], + ), + ] diff --git a/cntmanage/flightslot/models/instructors.py b/cntmanage/flightslot/models/instructors.py new file mode 100644 index 0000000..14663ab --- /dev/null +++ b/cntmanage/flightslot/models/instructors.py @@ -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" diff --git a/cntmanage/flightslot/models/missions.py b/cntmanage/flightslot/models/missions.py index a6125fd..816797e 100644 --- a/cntmanage/flightslot/models/missions.py +++ b/cntmanage/flightslot/models/missions.py @@ -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" ) diff --git a/cntmanage/flightslot/models/students.py b/cntmanage/flightslot/models/students.py index bab8035..fb3374f 100644 --- a/cntmanage/flightslot/models/students.py +++ b/cntmanage/flightslot/models/students.py @@ -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(), diff --git a/cntmanage/poetry.lock b/cntmanage/poetry.lock index 7042e04..3efef29 100644 --- a/cntmanage/poetry.lock +++ b/cntmanage/poetry.lock @@ -143,6 +143,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 +205,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 +468,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "e932d0af75c888d83fecefaaad1d018c508881a3bfde2ea640a82790e3567855" +content-hash = "5147211bd07992aff3915544175c8d95d77511b9d42273d17c4452fbef9299eb" diff --git a/cntmanage/pyproject.toml b/cntmanage/pyproject.toml index dfeccc8..35b3e97 100644 --- a/cntmanage/pyproject.toml +++ b/cntmanage/pyproject.toml @@ -18,6 +18,7 @@ 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"} [build-system]