Compare commits
18 Commits
polymorphi
...
airplane-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5291956a31 | |||
| 7f908157bf | |||
| 35f773047d | |||
| 65444e786b | |||
| 7a392df8ad | |||
| de39913275 | |||
| 33c610dcbc | |||
| 1c0b287666 | |||
| cc833c475f | |||
| 09584e22fd | |||
| 97b14ae1d7 | |||
| 18d2604121 | |||
| b32d0fd032 | |||
| bf9f43eed8 | |||
| 18953e06b7 | |||
| b79f0c318a | |||
| c91f603a50 | |||
| f7030e8da1 |
@@ -53,7 +53,6 @@ IMPORT_EXPORT_SKIP_ADMIN_LOG = True
|
||||
IMPORT_FORMATS = [CSV]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'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',
|
||||
@@ -61,6 +60,7 @@ MIDDLEWARE = [
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'flightslot.middleware.RedirectNonSuperuserFromAdminMiddleware', # custom middleware to show "user" page to non superuser
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'cntmanage.urls'
|
||||
|
||||
@@ -47,6 +47,7 @@ INSTALLED_APPS = [
|
||||
'colorfield',
|
||||
'import_export',
|
||||
'django_admin_action_forms',
|
||||
'polymorphic'
|
||||
]
|
||||
|
||||
# Import Export plugin settings
|
||||
@@ -56,7 +57,6 @@ IMPORT_EXPORT_SKIP_ADMIN_LOG = True
|
||||
IMPORT_FORMATS = [CSV]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'flightslot.middleware.RedirectNonSuperuserFromAdminMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
@@ -65,6 +65,7 @@ MIDDLEWARE = [
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'flightslot.middleware.RedirectNonSuperuserFromAdminMiddleware', # custom middleware to show "user" page to non superuser
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'cntmanage.urls'
|
||||
|
||||
5
cntmanage/docker/build.sh
Executable file
5
cntmanage/docker/build.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
GIT_HASH=$(git rev-parse --short HEAD)
|
||||
echo "Building Flightslot version ${GIT_HASH}"
|
||||
docker compose build --build-arg GIT_HASH=${GIT_HASH}
|
||||
@@ -22,6 +22,8 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: ./docker/flightslot.Dockerfile
|
||||
args:
|
||||
GIT_HASH:
|
||||
image: flightslot:latest
|
||||
container_name: tech-flightslot
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -23,5 +23,5 @@ else
|
||||
echo "👁️ Superuser ${DJANGO_SUPERUSER_USERNAME} created successfully ..."
|
||||
fi
|
||||
|
||||
echo "🚀 Launching Flightslot..."
|
||||
echo "🚀 Launching Flightslot version ${VERSION} ..."
|
||||
exec "$@"
|
||||
|
||||
@@ -4,7 +4,6 @@ FROM python:3.12 AS builder
|
||||
# Install Poetry
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="${PATH}:/root/.local/bin"
|
||||
RUN env
|
||||
# Create build directory
|
||||
WORKDIR /build
|
||||
# Copy project files
|
||||
@@ -14,28 +13,26 @@ RUN poetry update --no-interaction --no-ansi
|
||||
# Build project
|
||||
RUN poetry build
|
||||
|
||||
### STAGE 2 — Final image
|
||||
### STAGE 2 — Final image ###
|
||||
FROM python:3.12-slim AS deploy
|
||||
|
||||
# Create app run directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy application custom static files
|
||||
RUN mkdir -p static
|
||||
COPY ./static/cantorair.jpg ./static
|
||||
COPY ./static/cantorair_blue.jpg ./static
|
||||
|
||||
# Copy application custom templates for admin page
|
||||
RUN mkdir -p /templates/admin
|
||||
COPY ./templates/admin/* ./templates/admin/
|
||||
|
||||
# Copy and install application wheel package
|
||||
COPY --from=builder /build/dist/*.whl ./
|
||||
RUN pip install --no-cache-dir *.whl
|
||||
RUN pip install gunicorn whitenoise
|
||||
|
||||
# Copy entryupoint bash script
|
||||
COPY ./docker/entrypoint.sh ./
|
||||
# Collect build number from build arg
|
||||
ARG GIT_HASH
|
||||
ENV VERSION=${GIT_HASH}
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
# Command to be executed after entry point
|
||||
CMD ["gunicorn", "cntmanage.wsgi:application", "--bind", "0.0.0.0:8000"]
|
||||
CMD ["gunicorn", "cntmanage.wsgi:application", "--bind", "0.0.0.0:8000", "--timeout", "600"]
|
||||
|
||||
@@ -5,13 +5,12 @@ from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import List
|
||||
|
||||
from ..models.weekpref import WeekPreference
|
||||
from ..models.missions import Training
|
||||
from ..models.weekpref import WeekPreference
|
||||
from ..models.hourbuildings import HourBuilding, HourBuildingLegFlight, HourBuildingLegStop, HourBuildingLegBase
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import List
|
||||
|
||||
def export_selected(request: HttpRequest, queryset: QuerySet[WeekPreference]) -> HttpResponse:
|
||||
|
||||
@@ -79,12 +78,21 @@ 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("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_email: str = q.student.email
|
||||
student_course_type: str
|
||||
student_course_number: str
|
||||
student_course_ac: str
|
||||
if q.student.course:
|
||||
student_data = [f"{q.student.surname} {q.student.name}", f"{q.student.course.ctype}-{q.student.course.cnumber}"]
|
||||
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}",
|
||||
f"{student_course_type}-{student_course_number}"
|
||||
]
|
||||
else:
|
||||
student_data = [f"{q.student.surname} {q.student.name}", f"No Course Assigned"]
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
from django.contrib import admin
|
||||
from django.http import HttpRequest
|
||||
|
||||
from .models.aircrafts import Aircraft
|
||||
from .models.courses import Course
|
||||
from .models.students import Student
|
||||
from .models.missions import MissionProfile
|
||||
from .models.weekpref import WeekPreference
|
||||
|
||||
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.weekpred_adm import WeekPreferenceAdmin
|
||||
#from .admins.hourbuilding_adm import HourBuilding, HourBuildingInLine
|
||||
from .admins.weekpref_adm import WeekPreferenceAdmin
|
||||
|
||||
from django.contrib.admin import AdminSite
|
||||
|
||||
from os import environ
|
||||
|
||||
# User website under /user/ URL
|
||||
class FlightSlotUserSite(AdminSite):
|
||||
site_header = "Flight Scheduler 🛫"
|
||||
site_title = "Flight Scheduler 🛫"
|
||||
@@ -27,15 +31,19 @@ class FlightSlotUserSite(AdminSite):
|
||||
|
||||
return app_list
|
||||
|
||||
# Register only user visible models
|
||||
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 🛫"
|
||||
|
||||
# Get version for debug purposes
|
||||
ver: str = environ.get("VERSION", "dev")
|
||||
# Register all visible models
|
||||
admin.site.site_header = f"Flight Scheduler Admin 🛫 - ver.{ver}"
|
||||
admin.site.site_title = f"Flight Scheduler Admin 🛫 - ver.{ver}"
|
||||
admin.site.index_title = "Welcome to CantorAir Flight Scheduler Administrator Portal"
|
||||
|
||||
admin.site.register(Aircraft, AircraftAdmin)
|
||||
admin.site.register(Course, CourseAdmin)
|
||||
admin.site.register(MissionProfile, MissionProfileAdmin)
|
||||
admin.site.register(Student, StudentAdmin)
|
||||
|
||||
17
cntmanage/flightslot/admins/aircraft_adm.py
Normal file
17
cntmanage/flightslot/admins/aircraft_adm.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
from django.db.models.query import QuerySet
|
||||
from django.http import HttpRequest
|
||||
|
||||
from ..models.aircrafts import Aircraft
|
||||
|
||||
class AircraftAdmin(admin.ModelAdmin):
|
||||
model = Aircraft
|
||||
list_display = ("type", "markings", "avail_hours", "complex", )
|
||||
list_filter = ("type", )
|
||||
actions = ("reset_maint")
|
||||
|
||||
def get_queryset(self, request: HttpRequest) -> QuerySet[Aircraft]:
|
||||
qs: QuerySet[Aircraft] = super().get_queryset(request)
|
||||
qs.order_by("type", "markings")
|
||||
return qs
|
||||
|
||||
@@ -51,7 +51,6 @@ class HourBuildingLegBaseInLine(nested_admin.NestedStackedPolymorphicInline):
|
||||
form = HourBuildingLegFlightForm
|
||||
fk_name = "hourbuildinglegbase_ptr"
|
||||
fields = ("departure", "time", "destination", "pax", )
|
||||
hide_title = True
|
||||
|
||||
class HourBuildingLegStopInLine(nested_admin.NestedStackedPolymorphicInline.Child):
|
||||
model = HourBuildingLegStop
|
||||
@@ -61,18 +60,6 @@ class HourBuildingLegBaseInLine(nested_admin.NestedStackedPolymorphicInline):
|
||||
|
||||
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:
|
||||
current_week = date.today().isocalendar().week
|
||||
if not obj.DoesNotExist and current_week > obj.weekpref.week:
|
||||
return False
|
||||
return True
|
||||
|
||||
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,)
|
||||
@@ -85,3 +72,16 @@ class HourBuildingInLine(nested_admin.NestedTabularInline):
|
||||
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) -> bool:
|
||||
if hasattr(request.user, 'student') and obj:
|
||||
current_week: int = date.today().isocalendar().week
|
||||
if current_week > obj.week:
|
||||
return False
|
||||
return True
|
||||
|
||||
def has_delete_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
|
||||
return self.has_change_permission(request=request, obj=obj)
|
||||
|
||||
def has_add_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
|
||||
return self.has_change_permission(request=request, obj=obj)
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
from django.contrib import admin
|
||||
from django.forms import ModelMultipleChoiceField
|
||||
from django.contrib import admin, messages
|
||||
from django.http import HttpRequest
|
||||
from django.db.models.query import QuerySet, Q
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from typing import Any
|
||||
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.aircrafts import Aircraft
|
||||
from ..models.missions import MissionProfile
|
||||
|
||||
from django_admin_action_forms import AdminActionFormsMixin
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict
|
||||
|
||||
# Resource Class for Student data import
|
||||
class MissionProfileResource(ModelResource):
|
||||
@@ -20,20 +24,50 @@ class MissionProfileResource(ModelResource):
|
||||
duration = fields.Field(attribute="duration", column_name="duration")
|
||||
|
||||
# Cleanup fields before entering
|
||||
def before_import_row(self, row: dict[str, str | Any], **kwargs) -> None:
|
||||
def before_import_row(self, row: dict[str, str | Any], **kwargs):
|
||||
row["mtype"] = SafeText(row["mtype"].upper().strip())
|
||||
row["mnum"] = SafeText(row["mnum"].upper().strip())
|
||||
h, m, _ = row["duration"].split(":")
|
||||
row["duration"] = timedelta(hours=float(h), minutes=float(m))
|
||||
return super().before_import_row(row, **kwargs)
|
||||
super().before_import_row(row, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = MissionProfile
|
||||
skip_unchanged = True
|
||||
report_skipped = True
|
||||
fields = ("mtype", "mnum", "duration")
|
||||
import_id_fields = ("mtype", "mnum")
|
||||
fields = ("mtype", "mnum", "duration",)
|
||||
import_id_fields = ("mtype", "mnum",)
|
||||
|
||||
class MissionProfileAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
|
||||
list_display = ("mtype", "mnum", "notes")
|
||||
list_filter = ("mtype",)
|
||||
# Form class to assing aircrafts to students
|
||||
class ChangeAircraftForm(AdminActionForm):
|
||||
aircrafts = ModelMultipleChoiceField(queryset=Aircraft.objects.distinct('type').all())
|
||||
|
||||
class MissionProfileAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
|
||||
list_display = ("mtype", "mnum", "assigned_aircrafts", "duration", "notes", )
|
||||
list_filter = ("mtype", )
|
||||
actions = ("assign_aircraft", )
|
||||
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}")
|
||||
|
||||
@admin.display(description="Assigned Aircrafts")
|
||||
def assigned_aircrafts(self, obj: MissionProfile) -> SafeText:
|
||||
if not obj.aircrafts:
|
||||
return SafeText("")
|
||||
return SafeText("/".join(ac.markings for ac in obj.aircrafts.all()))
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
from django import forms
|
||||
from django.db.models.query import QuerySet
|
||||
from django.forms import ModelChoiceField, TypedMultipleChoiceField, ModelMultipleChoiceField
|
||||
from django.db.models.query import QuerySet, Q
|
||||
from django.http import HttpRequest
|
||||
from django.contrib import admin, messages
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
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 import_export.widgets import CharWidget
|
||||
from import_export.forms import ConfirmImportForm, ImportForm
|
||||
|
||||
from django_admin_action_forms import AdminActionFormsMixin, AdminActionForm, action_with_form
|
||||
|
||||
from ..models.aircrafts import Aircraft, AircraftTypes
|
||||
from ..models.courses import Course
|
||||
from ..models.students import Student
|
||||
|
||||
from ..custom.colortag import course_color
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
# Custom import form to select a course for student input
|
||||
class StudentCustomConfirmImportForm(ConfirmImportForm):
|
||||
course = ModelChoiceField(
|
||||
queryset=Course.objects.all(),
|
||||
required=False)
|
||||
|
||||
# Resource Class for Student data import
|
||||
class StudentResource(ModelResource):
|
||||
surname = fields.Field(attribute="surname", column_name="surname")
|
||||
@@ -24,29 +34,44 @@ class StudentResource(ModelResource):
|
||||
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(row["name"].capitalize().strip())
|
||||
row["surname"] = SafeText(row["surname"].capitalize().strip())
|
||||
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)
|
||||
|
||||
# If course was addedd as a form kwasrg add it to the student after creation
|
||||
def after_init_instance(self, instance: Student, new: bool, row: Dict[str, str], **kwargs: Dict[str, Any | Course]):
|
||||
course = kwargs.get("course", None)
|
||||
if course and isinstance(course, Course):
|
||||
instance.course = course
|
||||
|
||||
class Meta:
|
||||
model = Student
|
||||
skip_unchanged = True
|
||||
report_skipped = True
|
||||
fields = ("surname", "name", "email", "phone")
|
||||
import_id_fields = ("email", "phone")
|
||||
fields = ("surname", "name", "email", "phone",)
|
||||
import_id_fields = ("email", "phone",)
|
||||
|
||||
# Form Class for Student course change
|
||||
class ChangeCourseForm(AdminActionForm):
|
||||
course = forms.ModelChoiceField(queryset=Course.objects.all())
|
||||
course = TypedMultipleChoiceField(choices=AircraftTypes)
|
||||
|
||||
# Form class to assing aircrafts to students
|
||||
class ChangeAircraftForm(AdminActionForm):
|
||||
aircrafts = ModelMultipleChoiceField(queryset=Aircraft.objects.distinct('type').all())
|
||||
|
||||
class StudentAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
|
||||
list_display = ("surname", "name", "course", "course_color", "email", "phone", "password", "active")
|
||||
list_filter = ("course", "active")
|
||||
actions = ("change_course", "disable_students")
|
||||
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", )
|
||||
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:
|
||||
@@ -58,6 +83,10 @@ class StudentAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
|
||||
def password(self, obj: Student) -> SafeText:
|
||||
return SafeText(obj.default_password())
|
||||
|
||||
@admin.display(description="Username")
|
||||
def username(self, obj: Student) -> SafeText:
|
||||
return SafeText(obj.default_username())
|
||||
|
||||
@admin.action(description="Deactivate Students")
|
||||
def disable_students(self, request: HttpRequest, queryset: QuerySet[Student]):
|
||||
for q in queryset.all():
|
||||
@@ -66,10 +95,40 @@ class StudentAdmin(ImportMixin, AdminActionFormsMixin, admin.ModelAdmin):
|
||||
q.user.save()
|
||||
count: int = queryset.update(active = False)
|
||||
messages.success(request, f"{count} students deactivated")
|
||||
pass
|
||||
|
||||
@action_with_form(ChangeCourseForm, description="Change Student Course")
|
||||
def change_course(self, request: HttpRequest, queryset: QuerySet[Student], data):
|
||||
course = data["course"]
|
||||
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
|
||||
messages.success(request, f"{i} Students updated to {ac_types}")
|
||||
|
||||
# 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)
|
||||
if import_form and hasattr(import_form.cleaned_data, "course"):
|
||||
course: Course = import_form.cleaned_data["course"]
|
||||
initial["course"] = course.id
|
||||
return initial
|
||||
|
||||
# Add course to import form kwargs to be used by resource to associate course with all imported students
|
||||
def get_import_data_kwargs(self, request: HttpRequest, *args, **kwargs) -> Dict[str, Any]:
|
||||
form: ImportForm | None = kwargs.get("form", None)
|
||||
if form and hasattr(form, "cleaned_data"):
|
||||
kwargs["course"] = form.cleaned_data.get("course", None)
|
||||
return kwargs
|
||||
|
||||
@@ -26,12 +26,15 @@ class TrainingInLIne(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):
|
||||
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
|
||||
|
||||
def has_delete_permission(self, request: HttpRequest, obj: WeekPreference | None = None):
|
||||
def has_delete_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
|
||||
return self.has_change_permission(request=request, obj=obj)
|
||||
|
||||
def has_add_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
|
||||
return self.has_change_permission(request=request, obj=obj)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import nested_admin
|
||||
|
||||
from django import forms
|
||||
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
|
||||
@@ -17,11 +18,13 @@ from ..custom.colortag import course_color
|
||||
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", "student",)
|
||||
list_filter = ("week", "student__course",)
|
||||
search_fields = ("student__surname","student__name",)
|
||||
actions = ("export",)
|
||||
|
||||
@admin.action(description="Export Selected Preferences")
|
||||
@@ -44,83 +47,75 @@ class WeekPreferenceAdmin(nested_admin.NestedPolymorphicModelAdmin):
|
||||
return course_color(obj.student.course.color)
|
||||
|
||||
# If a user is registered as student hide filters
|
||||
def get_list_filter(self, request):
|
||||
def get_list_filter(self, request: HttpRequest) -> List[str]:
|
||||
list_filter = super().get_list_filter(request)
|
||||
if hasattr(request.user, 'student'):
|
||||
if hasattr(request.user, "student"):
|
||||
return []
|
||||
return list_filter
|
||||
|
||||
# If a user is registered as student do not show actions
|
||||
def get_actions(self, request):
|
||||
def get_actions(self, request: HttpRequest) -> Dict[str, Any]:
|
||||
actions = super().get_actions(request)
|
||||
if hasattr(request.user, 'student'):
|
||||
return []
|
||||
if hasattr(request.user, "student"):
|
||||
return {}
|
||||
return actions
|
||||
|
||||
# If a user is registered as student show only their preferences
|
||||
def get_queryset(self, request):
|
||||
def get_queryset(self, request: HttpRequest) -> QuerySet[WeekPreference]:
|
||||
qs = super().get_queryset(request)
|
||||
if hasattr(request.user, 'student'):
|
||||
if hasattr(request.user, "student"):
|
||||
return qs.filter(student=request.user.student)
|
||||
# If admin show everything
|
||||
return qs
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form: forms.Form = super().get_form(request, obj, **kwargs)
|
||||
def get_form(self, request: HttpRequest, obj: WeekPreference | None = None, **kwargs: Dict[str, Any]) -> Form:
|
||||
form: Form = super().get_form(request, obj, **kwargs)
|
||||
current_week = date.today().isocalendar().week
|
||||
|
||||
# If form contains the week field
|
||||
if 'week' in form.base_fields:
|
||||
if "week" in form.base_fields:
|
||||
# Set default value as current week
|
||||
form.base_fields['week'].initial = current_week
|
||||
form.base_fields["week"].initial = current_week
|
||||
|
||||
# If student is current user making request
|
||||
if hasattr(request.user, 'student'):
|
||||
if hasattr(request.user, "student"):
|
||||
student = request.user.student
|
||||
if 'student' in form.base_fields:
|
||||
form.base_fields['student'].initial = student
|
||||
form.base_fields['student'].disabled = True
|
||||
form.base_fields['week'].disabled = True # student cannot change week
|
||||
if "student" in form.base_fields:
|
||||
form.base_fields["student"].initial = student
|
||||
form.base_fields["student"].disabled = True
|
||||
form.base_fields["week"].disabled = True # student cannot change week
|
||||
return form
|
||||
|
||||
# If user is a student deny edit permission for week past the current one
|
||||
def has_change_permission(self, request, obj: WeekPreference | None = None):
|
||||
if hasattr(request.user, 'student') and obj:
|
||||
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
|
||||
|
||||
# If user is a student deny edit permission for week past the current one
|
||||
def has_add_permission(self, request, 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_add_permission(self, request: HttpRequest, obj: WeekPreference | None = None) -> bool:
|
||||
return self.has_change_permission(request, obj)
|
||||
|
||||
# If user is a student deny edit permission for week past the current one
|
||||
def has_delete_permission(self, request, 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)-> 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:
|
||||
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:
|
||||
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
|
||||
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, obj, form, change):
|
||||
def save_model(self, request: HttpRequest, obj, form: Form, change: bool):
|
||||
# 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
|
||||
super().save_model(request, obj, form, change)
|
||||
@@ -8,11 +8,7 @@ class RedirectNonSuperuserFromAdminMiddleware:
|
||||
|
||||
def __call__(self, request: HttpRequest):
|
||||
# Se l'utente è loggato, non è superuser e prova ad andare in /admin/...
|
||||
if hasattr(request,"user"):
|
||||
if (
|
||||
request.path.startswith("/admin/") and
|
||||
hasattr(request.user, 'student')
|
||||
):
|
||||
if hasattr(request, "user") and not request.user.is_superuser:
|
||||
if "/admin/" in request.path:
|
||||
return redirect("/user/") # redirect automatico
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-25 11:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('flightslot', '0019_remove_hourbuildinglegstop_location_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='hourbuildinglegbase',
|
||||
options={'base_manager_name': 'objects', 'verbose_name': 'Flight Leg or Stop', 'verbose_name_plural': 'Flight Legs or Stops'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='hourbuildinglegflight',
|
||||
options={'base_manager_name': 'objects', 'verbose_name': 'Flight leg', 'verbose_name_plural': 'Flight legs'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='hourbuildinglegstop',
|
||||
options={'base_manager_name': 'objects', 'verbose_name': 'Stop', 'verbose_name_plural': 'Stops'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='hourbuildinglegflight',
|
||||
name='pax',
|
||||
field=models.CharField(blank=True, max_length=16, null=True, verbose_name='Pax (optional)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='weekpreference',
|
||||
name='week',
|
||||
field=models.PositiveSmallIntegerField(auto_created=True, db_default=48, db_index=True, verbose_name='Week Number'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-27 10:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('flightslot', '0020_alter_hourbuildinglegbase_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='hourbuildinglegstop',
|
||||
name='refuel',
|
||||
field=models.BooleanField(default=False, verbose_name='Stop for Refuelling'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-27 13:34
|
||||
|
||||
import colorfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('flightslot', '0021_alter_hourbuildinglegstop_refuel'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Aircraft',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('type', models.CharField(choices=[('C152', 'Cessna 152'), ('P208', 'Tecnam P2008'), ('PA28', 'Piper PA28R'), ('PA34', 'Piper PA34'), ('C182', 'Cessna 182Q'), ('TWEN', 'Tecnam P2010'), ('FSTD', 'Alsim ALX40')], max_length=4)),
|
||||
('markings', models.CharField(max_length=6)),
|
||||
('complex', models.BooleanField(default=False)),
|
||||
('avail_hours', models.DurationField(null=True, verbose_name='Time until maintenance')),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='course',
|
||||
name='color',
|
||||
field=colorfield.fields.ColorField(default='#FFFFFF', image_field=None, max_length=25, samples=[('#bfbfbf', 'GREY'), ('#ff0000', 'RED'), ('#ffc000', 'ORANGE'), ('#ffff00', 'YELLOW'), ('#92d050', 'GREEN'), ('#00b0f0', 'CYAN'), ('#b1a0c7', 'MAGENTA'), ('#fabcfb', 'PINK'), ('#f27ae4', 'VIOLET')], verbose_name='Binder Color'),
|
||||
),
|
||||
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'), ('FSTD', 'Alsim ALX40')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='aircrafts',
|
||||
field=models.ManyToManyField(to='flightslot.aircraft'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 09:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('flightslot', '0022_aircraft_alter_course_color_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='missionprofile',
|
||||
name='aircrafts',
|
||||
field=models.ManyToManyField(to='flightslot.aircraft'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 10:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('flightslot', '0023_missionprofile_aircrafts'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='missionprofile',
|
||||
name='mtype',
|
||||
field=models.CharField(choices=[('OTHER', 'OTHER'), ('CHK', 'CHK_6M'), ('PPL', 'PPL'), ('IR', 'IR'), ('MEP', 'MEP'), ('MEP_IR', 'MEP_IR'), ('CPL', 'CPL'), ('UPRT', 'UPRT'), ('FI', 'FI'), ('PC', 'PC')], default='PPL', verbose_name='Mission Type'),
|
||||
),
|
||||
]
|
||||
@@ -5,5 +5,45 @@ class AircraftTypes(models.TextChoices):
|
||||
C152 = "C152", _("Cessna 152")
|
||||
P208 = "P208", _("Tecnam P2008")
|
||||
PA28 = "PA28", _("Piper PA28R")
|
||||
PA34 = "PA34", _("Piper PA34")
|
||||
C182 = "C182", _("Cessna 182Q")
|
||||
P210 = "TWEN", _("Tecnam P2010")
|
||||
ALX40 = "FSTD", _("Alsim ALX40")
|
||||
|
||||
class Aircraft(models.Model):
|
||||
id = models.AutoField(
|
||||
primary_key=True
|
||||
)
|
||||
|
||||
type = models.CharField(
|
||||
null=False,
|
||||
blank=False,
|
||||
max_length=4, # ICAO naming of aircraft,
|
||||
choices=AircraftTypes
|
||||
)
|
||||
|
||||
markings = models.CharField(
|
||||
null=False,
|
||||
blank=False,
|
||||
max_length=6
|
||||
)
|
||||
|
||||
complex = models.BooleanField(
|
||||
null=False,
|
||||
default=False
|
||||
)
|
||||
|
||||
avail_hours = models.DurationField(
|
||||
null=True,
|
||||
verbose_name=_("Time until maintenance")
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.type} ({self.markings})"
|
||||
|
||||
# Insert dash between first and rest, I-OASM
|
||||
def save(self, *args, **kwargs):
|
||||
self.markings = self.markings.upper()
|
||||
if not "-" in self.markings:
|
||||
self.markings = self.markings[0] + "-" + self.markings[1:]
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -10,14 +10,18 @@ class CourseTypes(models.TextChoices):
|
||||
DISTANCE = "DL", _("DISTANCE")
|
||||
OTHER = "OTHER",_("OTHER")
|
||||
|
||||
|
||||
class Course(models.Model):
|
||||
# Add colors according to table from Alessia
|
||||
COLOR_PALETTE = [
|
||||
("#ffffff","WHITE"),
|
||||
("#bfbfbf","GREY"),
|
||||
("#ff0000", "RED"),
|
||||
("#00ff00", "GREEN"),
|
||||
("#0000ff", "BLUE")
|
||||
("#ffc000", "ORANGE"),
|
||||
("#ffff00", "YELLOW"),
|
||||
("#92d050", "GREEN"),
|
||||
("#00b0f0", "CYAN"),
|
||||
("#b1a0c7", "MAGENTA"),
|
||||
("#fabcfb", "PINK"),
|
||||
("#f27ae4", "VIOLET"),
|
||||
]
|
||||
|
||||
id = models.AutoField(
|
||||
@@ -43,8 +47,8 @@ class Course(models.Model):
|
||||
)
|
||||
|
||||
color = ColorField (
|
||||
samples=COLOR_PALETTE,
|
||||
verbose_name=_("Binder Color")
|
||||
verbose_name=_("Binder Color"),
|
||||
samples=COLOR_PALETTE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -124,8 +124,10 @@ class HourBuildingLegFlight(HourBuildingLegBase):
|
||||
|
||||
class HourBuildingLegStop(HourBuildingLegBase):
|
||||
|
||||
refuel = models.BooleanField(
|
||||
default=False
|
||||
refuel = models.BooleanField (
|
||||
null=False,
|
||||
default=False,
|
||||
verbose_name="Stop for Refuelling"
|
||||
)
|
||||
|
||||
# Change displayed name in the inline form
|
||||
|
||||
@@ -3,16 +3,19 @@ from django.db import models
|
||||
from datetime import timedelta
|
||||
|
||||
from ..models.weekpref import WeekPreference
|
||||
from ..models.aircrafts import Aircraft
|
||||
|
||||
class MissionType(models.TextChoices):
|
||||
OTHER = "OTHER", _("OTHER")
|
||||
CHK = "CHK", _("CHK_6M")
|
||||
PPL = "PPL", _("PPL")
|
||||
IR = "IR", _("IR")
|
||||
MEP = "MEP", _("MEP")
|
||||
MEP_IR = "MEP_IR", _("MEP_IR")
|
||||
CPL = "CPL", _("CPL")
|
||||
UPRT = "UPRT", _("UPRT")
|
||||
FI = "FI", _("FI")
|
||||
PC = "PC", _("PC")
|
||||
CHK = "CHK", _("CHK_6M")
|
||||
|
||||
class MissionProfile(models.Model):
|
||||
id = models.AutoField(
|
||||
@@ -37,6 +40,10 @@ class MissionProfile(models.Model):
|
||||
default=timedelta(hours=1)
|
||||
)
|
||||
|
||||
aircrafts = models.ManyToManyField(
|
||||
Aircraft
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
max_length=140,
|
||||
null=True,
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.db import models
|
||||
from django.contrib.auth.models import User, Group
|
||||
|
||||
from ..models.courses import Course
|
||||
from ..models.aircrafts import Aircraft
|
||||
|
||||
class Student(models.Model):
|
||||
id = models.AutoField(
|
||||
@@ -46,8 +47,18 @@ class Student(models.Model):
|
||||
blank=True
|
||||
)
|
||||
|
||||
def default_password(self) -> str:
|
||||
return f"{self.name.lower()[0]}{self.surname.lower()}{self.id}"
|
||||
aircrafts = models.ManyToManyField(
|
||||
Aircraft
|
||||
)
|
||||
|
||||
def default_password(self) -> str: # Maximum 4 digits for passowrd
|
||||
return f"{self.name.lower()[0]}{self.surname.lower()}{self.id % 10000}"
|
||||
|
||||
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):
|
||||
|
||||
8
cntmanage/poetry.lock
generated
8
cntmanage/poetry.lock
generated
@@ -385,18 +385,18 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.3"
|
||||
version = "0.5.4"
|
||||
description = "A non-validating SQL parser."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"},
|
||||
{file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"},
|
||||
{file = "sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb"},
|
||||
{file = "sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["build", "hatch"]
|
||||
dev = ["build"]
|
||||
doc = ["sphinx"]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "cntmanage"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
packages = [{include = "flightslot"}, {include = "cntmanage"}]
|
||||
description = "CantorAir Flight Scheduler"
|
||||
authors = ["Emanuele <ema.trabattoni@gmail.com>"]
|
||||
|
||||
Reference in New Issue
Block a user