From 21bfd1c07509e12189ccf33b576506a5be324bee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:57:48 +0000 Subject: [PATCH 1/9] feat: add ensure_academic_years command and fix year-title sort order Agent-Logs-Url: https://github.com/it-amanuens/rodatraden/sessions/a6e01129-76ef-4f7e-9b95-aafef4f01b75 Co-authored-by: SpiderQubit <25958778+SpiderQubit@users.noreply.github.com> --- MIGRATION_GUIDE.md | 23 ++- README.md | 2 +- .../commands/ensure_academic_years.py | 135 ++++++++++++++++++ rodatraden/views.py | 14 +- 4 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 rodatraden/management/commands/ensure_academic_years.py diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 9ab4860..263fae8 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -117,7 +117,28 @@ Kontrollera att segmenten matchar befintliga kurstillfällen: python manage.py validate_course_schedule_parity ``` -### 11. Starta om servern +### 11. Säkerställ akademiska år + +Kör kommandot som automatiskt skapar `AcademicYear`-poster för alla år från +startåret (standard 2011) till och med innevarande år + 10. Kommandot är +idempotent och kan köras hur många gånger som helst utan att befintliga poster +påverkas. + +```bash +# Förhandsvisning (ingen data ändras) +python manage.py ensure_academic_years + +# Skapa saknade poster +python manage.py ensure_academic_years --apply + +# Justera antalet framtida år (standard: 10) +python manage.py ensure_academic_years --apply --future-years 5 +``` + +Lägg med fördel in kommandot i ett återkommande jobb (t.ex. cron eller ett +driftsättningsskript) så att nya år läggs till automatiskt varje år. + +### 12. Starta om servern Starta om webbservern (t.ex. IIS, gunicorn, eller liknande). diff --git a/README.md b/README.md index 682283c..8ccd15b 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ This setup has not been fully tested on Windows, so some adjustment is probably If you don't want to migrate data from an old instance, then you will have to create new the all new data from scratch. New super users can be created in django by running this command `python manage.py createsuperuser` After this navigate to the admin interface. Here we will have to add entries like "akademiska år", "institutioner" "Nivåer", "Spår" and "Tidsperioder". -- Akademiska år : The study year (You probably want a few courses here) +- Akademiska år : The study year (run `python manage.py ensure_academic_years --apply` to auto-populate years from 2011 to current year + 10, instead of adding them manually) - Institutioner : The institution / faculty where the course can be / is taken - Nivåer : If it's an advanced course or not (grundläggande / avancerad) - Spår : The different tracks that can be choosen. Make a profile before on the main site diff --git a/rodatraden/management/commands/ensure_academic_years.py b/rodatraden/management/commands/ensure_academic_years.py new file mode 100644 index 0000000..fe1cff3 --- /dev/null +++ b/rodatraden/management/commands/ensure_academic_years.py @@ -0,0 +1,135 @@ +""" +Ensure AcademicYear rows exist for every year from a base year up to +current year + a configurable future buffer. + +The command is idempotent: it only creates rows that are missing and never +modifies existing ones. Run it once during initial setup and then once per +year (e.g. from a cron job or a deployment script) so that new years are +added automatically. + +Title format: year 2011 → "11/12", year 2020 → "20/21" (same convention +as the AcademicYear model docstring: "year 2018 is associated to period 18/19"). + +Usage: + python manage.py ensure_academic_years # dry-run + python manage.py ensure_academic_years --apply # create missing rows + python manage.py ensure_academic_years --apply --future-years 5 + python manage.py ensure_academic_years --apply --base-year 2012 +""" + +import datetime + +from django.core.management.base import BaseCommand + +from rodatraden.models import AcademicYear + +# Default starting year. The first AcademicYear in production is around 2011. +DEFAULT_BASE_YEAR = 2011 + +# How many years beyond the current calendar year to pre-create. +DEFAULT_FUTURE_BUFFER = 10 + + +def _year_title(year: int) -> str: + """Return the conventional title string for an academic year. + + Example: 2011 → "11/12", 2020 → "20/21". + """ + return f"{str(year)[2:]}/{str(year + 1)[2:]}" + + +class Command(BaseCommand): + help = ( + "Ensure AcademicYear rows exist from a base year up to " + "current year + future buffer. Safe to re-run at any time." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--apply", + action="store_true", + help="Actually create missing rows (default is dry-run).", + ) + parser.add_argument( + "--base-year", + type=int, + default=None, + help=( + f"First year to ensure exists. Defaults to the minimum year " + f"already in the database, or {DEFAULT_BASE_YEAR} if the " + f"table is empty." + ), + ) + parser.add_argument( + "--future-years", + type=int, + default=DEFAULT_FUTURE_BUFFER, + help=( + f"Number of years beyond the current year to pre-create " + f"(default: {DEFAULT_FUTURE_BUFFER})." + ), + ) + + def handle(self, *args, **options): + apply = options["apply"] + future_buffer = options["future_years"] + + if not apply: + self.stdout.write( + self.style.WARNING( + "DRY-RUN MODE — no rows will be created. " + "Use --apply to write.\n" + ) + ) + + # Determine base year: explicit arg → min existing DB year → hardcoded default. + if options["base_year"] is not None: + base_year = options["base_year"] + else: + existing_min = AcademicYear.objects.order_by("year").values_list( + "year", flat=True + ).first() + base_year = existing_min if existing_min is not None else DEFAULT_BASE_YEAR + + current_year = datetime.date.today().year + end_year = current_year + future_buffer + + self.stdout.write( + f"Ensuring AcademicYear rows for {base_year} – {end_year} " + f"(current year: {current_year}, buffer: +{future_buffer})\n" + ) + + created_count = 0 + skipped_count = 0 + + for year in range(base_year, end_year + 1): + title = _year_title(year) + if apply: + _, created = AcademicYear.objects.get_or_create( + year=year, + defaults={"title": title}, + ) + else: + created = not AcademicYear.objects.filter(year=year).exists() + + if created: + created_count += 1 + self.stdout.write( + f" {'CREATE' if apply else 'WOULD CREATE'} {year} ({title})" + ) + else: + skipped_count += 1 + + self.stdout.write("\n" + "=" * 60) + self.stdout.write( + f"Range: {base_year} – {end_year}\n" + f"Created: {created_count}\n" + f"Skipped (already exist): {skipped_count}" + ) + + if not apply: + self.stdout.write( + self.style.WARNING("\nNo rows created. Run with --apply to write.") + ) + else: + self.stdout.write(self.style.SUCCESS("\nDone.")) diff --git a/rodatraden/views.py b/rodatraden/views.py index 2defe19..3d4eafe 100644 --- a/rodatraden/views.py +++ b/rodatraden/views.py @@ -260,9 +260,19 @@ def _generate_occasions_all_years(request): if not dry_run: co.delete() - # Convert to list sorted by year title + # Build a lookup so results are ordered by the numeric year, not the title + # string. This is important because title strings like "11/12" sort + # correctly for 2000-2099, but the mapping makes it robust to any title + # format that was manually entered in the database. + title_to_year = { + ay.title: ay.year + for ay in AcademicYear.objects.only('title', 'year') + } + + # Convert to list sorted by numeric year (unknown titles fall back to their + # string representation so they still appear rather than causing an error). results = [] - for yr in sorted(year_results.keys()): + for yr in sorted(year_results.keys(), key=lambda t: title_to_year.get(t, t)): data = year_results[yr] if data['created'] or data['skipped_exists'] or data['removed']: results.append({ From 925079387f5ef1a2ff77a988777b2e3ec6aa6d33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:59:20 +0000 Subject: [PATCH 2/9] fix: use float('inf') sentinel and document year-title century boundary Agent-Logs-Url: https://github.com/it-amanuens/rodatraden/sessions/a6e01129-76ef-4f7e-9b95-aafef4f01b75 Co-authored-by: SpiderQubit <25958778+SpiderQubit@users.noreply.github.com> --- rodatraden/management/commands/ensure_academic_years.py | 5 +++++ rodatraden/views.py | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/rodatraden/management/commands/ensure_academic_years.py b/rodatraden/management/commands/ensure_academic_years.py index fe1cff3..b8f0133 100644 --- a/rodatraden/management/commands/ensure_academic_years.py +++ b/rodatraden/management/commands/ensure_academic_years.py @@ -34,6 +34,11 @@ def _year_title(year: int) -> str: """Return the conventional title string for an academic year. Example: 2011 → "11/12", 2020 → "20/21". + + Note: this format uses only the last two digits of each calendar year and + is only meaningful for years in the range 2000–2099. The base year + (DEFAULT_BASE_YEAR = 2011) and the ten-year future buffer keep all + generated titles well within that range. """ return f"{str(year)[2:]}/{str(year + 1)[2:]}" diff --git a/rodatraden/views.py b/rodatraden/views.py index 3d4eafe..0fbbb1a 100644 --- a/rodatraden/views.py +++ b/rodatraden/views.py @@ -269,10 +269,12 @@ def _generate_occasions_all_years(request): for ay in AcademicYear.objects.only('title', 'year') } - # Convert to list sorted by numeric year (unknown titles fall back to their - # string representation so they still appear rather than causing an error). + # Convert to list sorted by numeric year. Titles not found in the lookup + # (e.g. manually entered with a non-standard format) are placed at the end + # using a sentinel so the sort key is always an integer. results = [] - for yr in sorted(year_results.keys(), key=lambda t: title_to_year.get(t, t)): + for yr in sorted(year_results.keys(), + key=lambda t: title_to_year.get(t, float('inf'))): data = year_results[yr] if data['created'] or data['skipped_exists'] or data['removed']: results.append({ From 2f87ed9d5b6c8489a355d572bf47feb66b92cb3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:18:35 +0000 Subject: [PATCH 3/9] refactor: replace AcademicYear FK with plain IntegerField on CourseOccasion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the AcademicYear model entirely. CourseOccasion.year is now a plain IntegerField (matching PrivateCourse.year). Display titles like "20/21" are computed by academic_year_title(). All year dropdowns in forms now populate dynamically (current year ± 10) with no database rows needed. Files changed: models.py, forms.py, filters.py, views.py, tables.py, admin.py, functions.py, 5 templates, 3 management commands. Deleted: ensure_academic_years management command. Agent-Logs-Url: https://github.com/it-amanuens/rodatraden/sessions/b816d356-cc25-4262-a918-9016ea81368b Co-authored-by: SpiderQubit <25958778+SpiderQubit@users.noreply.github.com> --- rodatraden/admin.py | 1 - rodatraden/filters.py | 19 ++- rodatraden/forms.py | 50 +++++-- .../commands/ensure_academic_years.py | 140 ------------------ .../commands/generate_course_occasions.py | 17 +-- .../commands/infer_course_scheduling.py | 4 +- .../validate_course_schedule_parity.py | 4 +- rodatraden/models.py | 45 +++--- rodatraden/rodatraden_modules/functions.py | 6 +- rodatraden/tables.py | 8 +- .../courseoccasion_confirm_delete.html | 2 +- .../courseoccasion/courseoccasion_detail.html | 6 +- .../courseoccasion/courseoccasion_info.html | 2 +- .../rt_modal/course_list_table_row.html | 2 +- .../tables/courseoccasion_edit.html | 4 +- rodatraden/templates/rodatraden/tools.html | 4 +- rodatraden/views.py | 106 +++++++------ 17 files changed, 155 insertions(+), 265 deletions(-) delete mode 100644 rodatraden/management/commands/ensure_academic_years.py diff --git a/rodatraden/admin.py b/rodatraden/admin.py index 17c68b6..f96933b 100644 --- a/rodatraden/admin.py +++ b/rodatraden/admin.py @@ -10,7 +10,6 @@ Department, Level, Track, - AcademicYear, TimePeriod, ISPTemplate, CourseScheduleSegment, diff --git a/rodatraden/filters.py b/rodatraden/filters.py index 70022b1..29260e5 100644 --- a/rodatraden/filters.py +++ b/rodatraden/filters.py @@ -1,7 +1,8 @@ +import datetime import django_filters from .models import ( - Course, Category, Level, Department, Profile, Track, AcademicYear, - CourseOccasion, TimePeriod + Course, Category, Level, Department, Profile, Track, + CourseOccasion, TimePeriod, academic_year_title ) class CourseFilter(django_filters.FilterSet): @@ -60,9 +61,17 @@ class CourseOccasionFilter(django_filters.FilterSet): queryset=Course.objects.all().order_by('title'), empty_label='Kursnamn', to_field_name='id', field_name='course' ) - year = django_filters.ModelChoiceFilter( - queryset=AcademicYear.objects.all().order_by('year'), - empty_label='Läsår', field_name='academic_year' + # Year is now a plain IntegerField — use ChoiceFilter with dynamic choices. + year = django_filters.ChoiceFilter( + choices=lambda: [('', 'Läsår')] + [ + (y, academic_year_title(y)) + for y in range( + datetime.date.today().year - 10, + datetime.date.today().year + 11, + ) + ], + empty_label=None, # we include the empty option in choices above + field_name='year' ) time_period = django_filters.ModelChoiceFilter( queryset=TimePeriod.objects.all().order_by('week'), diff --git a/rodatraden/forms.py b/rodatraden/forms.py index 0666631..754a23f 100644 --- a/rodatraden/forms.py +++ b/rodatraden/forms.py @@ -5,8 +5,8 @@ from .models import ( Course, Block, CourseOccasion, CourseScheduleSegment, Category, CategoryCourse, Prerequisite, Profile, - AcademicYear, CategoryExam, Exam, Report, PrivateCourse, - PrivateCourseCategory, User + CategoryExam, Exam, Report, PrivateCourse, + PrivateCourseCategory, User, academic_year_title ) from .rodatraden_modules.mixins import ( CategoryFormMixin, PrerequisiteFormMixin, SaveAndImportBlockMixin @@ -16,6 +16,19 @@ from bootstrap_modal_forms.forms import BSModalForm, BSModalModelForm +def _dynamic_year_choices(extra_years=10): + """Build (value, label) tuples for current year ± extra_years. + + The range is wide enough to cover both historical and future course + occasions without needing pre-allocated database rows. + """ + current = datetime.date.today().year + return [ + (y, academic_year_title(y)) + for y in range(current - extra_years, current + extra_years + 1) + ] + + class UpdateUserForm(forms.ModelForm): """Form for updating user""" @@ -111,8 +124,8 @@ def __init__(self, *args, **kwargs): # Build the fields from these categories self._build_category_fields(categories) - # Get choices given from academic years - year_choices = [(x.year, x.title) for x in AcademicYear.objects.all().order_by('year')] + # Year choices computed dynamically — no database query needed. + year_choices = _dynamic_year_choices() self.fields['year'] = forms.ChoiceField(choices=year_choices, initial=datetime.datetime.now().year) # Always save to current user @@ -164,10 +177,10 @@ def __init__(self, *args, **kwargs): if 'course' in self.request.GET: self.initial['course'] = self.request.GET['course'] - # Year choices from AcademicYear (str keys for ChoiceField compat) + # Year choices computed dynamically (str keys for ChoiceField compat). year_choices = [ - (str(x.year), x.title) - for x in AcademicYear.objects.all().order_by('year') + (str(y), label) + for y, label in _dynamic_year_choices() ] self.fields['start_year'] = forms.ChoiceField( choices=year_choices, @@ -217,15 +230,21 @@ class CourseOccasionForm(BSModalModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Keep academic year options in chronological order. - self.fields['academic_year'].queryset = AcademicYear.objects.all().order_by('year') + # Year is now a plain IntegerField — present as a dropdown with + # dynamically computed year choices (current year ± 10). + year_choices = _dynamic_year_choices() + self.fields['year'] = forms.ChoiceField( + choices=year_choices, + initial=datetime.datetime.now().year, + label='År', + ) # If a courseoccasion is copied if 'courseocc' in self.request.GET: courseocc_cpy = CourseOccasion.objects.get(slug=self.request.GET['courseocc']) # This hard-coding might be avoidable. Not sure... self.fields['course'].initial = courseocc_cpy.course - self.fields['academic_year'].initial = courseocc_cpy.academic_year + self.fields['year'].initial = courseocc_cpy.year self.fields['time_period'].initial = courseocc_cpy.time_period self.fields['weeks'].initial = courseocc_cpy.weeks self.fields['note'].initial = courseocc_cpy.note @@ -236,9 +255,13 @@ def __init__(self, *args, **kwargs): self.fields['course'].widget.attrs['class'] = \ 'course-list-filter-courseocc-create' + def clean_year(self): + """Coerce the ChoiceField string back to int for the IntegerField.""" + return int(self.cleaned_data['year']) + class Meta: model = CourseOccasion - fields = ['course', 'academic_year', 'time_period', 'weeks', + fields = ['course', 'year', 'time_period', 'weeks', 'note', 'contact_name', 'contact_email', 'official'] @@ -257,9 +280,8 @@ class BlockForm(SaveAndImportBlockMixin, BSModalModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Get choices given from academic years and sort - years = [(x.year, x.year) for x in AcademicYear.objects.all()] - years.sort() + # Year choices computed dynamically — no database query needed. + years = _dynamic_year_choices() # Use can import form all public blocks published in a track and all # their own blocks. diff --git a/rodatraden/management/commands/ensure_academic_years.py b/rodatraden/management/commands/ensure_academic_years.py deleted file mode 100644 index b8f0133..0000000 --- a/rodatraden/management/commands/ensure_academic_years.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -Ensure AcademicYear rows exist for every year from a base year up to -current year + a configurable future buffer. - -The command is idempotent: it only creates rows that are missing and never -modifies existing ones. Run it once during initial setup and then once per -year (e.g. from a cron job or a deployment script) so that new years are -added automatically. - -Title format: year 2011 → "11/12", year 2020 → "20/21" (same convention -as the AcademicYear model docstring: "year 2018 is associated to period 18/19"). - -Usage: - python manage.py ensure_academic_years # dry-run - python manage.py ensure_academic_years --apply # create missing rows - python manage.py ensure_academic_years --apply --future-years 5 - python manage.py ensure_academic_years --apply --base-year 2012 -""" - -import datetime - -from django.core.management.base import BaseCommand - -from rodatraden.models import AcademicYear - -# Default starting year. The first AcademicYear in production is around 2011. -DEFAULT_BASE_YEAR = 2011 - -# How many years beyond the current calendar year to pre-create. -DEFAULT_FUTURE_BUFFER = 10 - - -def _year_title(year: int) -> str: - """Return the conventional title string for an academic year. - - Example: 2011 → "11/12", 2020 → "20/21". - - Note: this format uses only the last two digits of each calendar year and - is only meaningful for years in the range 2000–2099. The base year - (DEFAULT_BASE_YEAR = 2011) and the ten-year future buffer keep all - generated titles well within that range. - """ - return f"{str(year)[2:]}/{str(year + 1)[2:]}" - - -class Command(BaseCommand): - help = ( - "Ensure AcademicYear rows exist from a base year up to " - "current year + future buffer. Safe to re-run at any time." - ) - - def add_arguments(self, parser): - parser.add_argument( - "--apply", - action="store_true", - help="Actually create missing rows (default is dry-run).", - ) - parser.add_argument( - "--base-year", - type=int, - default=None, - help=( - f"First year to ensure exists. Defaults to the minimum year " - f"already in the database, or {DEFAULT_BASE_YEAR} if the " - f"table is empty." - ), - ) - parser.add_argument( - "--future-years", - type=int, - default=DEFAULT_FUTURE_BUFFER, - help=( - f"Number of years beyond the current year to pre-create " - f"(default: {DEFAULT_FUTURE_BUFFER})." - ), - ) - - def handle(self, *args, **options): - apply = options["apply"] - future_buffer = options["future_years"] - - if not apply: - self.stdout.write( - self.style.WARNING( - "DRY-RUN MODE — no rows will be created. " - "Use --apply to write.\n" - ) - ) - - # Determine base year: explicit arg → min existing DB year → hardcoded default. - if options["base_year"] is not None: - base_year = options["base_year"] - else: - existing_min = AcademicYear.objects.order_by("year").values_list( - "year", flat=True - ).first() - base_year = existing_min if existing_min is not None else DEFAULT_BASE_YEAR - - current_year = datetime.date.today().year - end_year = current_year + future_buffer - - self.stdout.write( - f"Ensuring AcademicYear rows for {base_year} – {end_year} " - f"(current year: {current_year}, buffer: +{future_buffer})\n" - ) - - created_count = 0 - skipped_count = 0 - - for year in range(base_year, end_year + 1): - title = _year_title(year) - if apply: - _, created = AcademicYear.objects.get_or_create( - year=year, - defaults={"title": title}, - ) - else: - created = not AcademicYear.objects.filter(year=year).exists() - - if created: - created_count += 1 - self.stdout.write( - f" {'CREATE' if apply else 'WOULD CREATE'} {year} ({title})" - ) - else: - skipped_count += 1 - - self.stdout.write("\n" + "=" * 60) - self.stdout.write( - f"Range: {base_year} – {end_year}\n" - f"Created: {created_count}\n" - f"Skipped (already exist): {skipped_count}" - ) - - if not apply: - self.stdout.write( - self.style.WARNING("\nNo rows created. Run with --apply to write.") - ) - else: - self.stdout.write(self.style.SUCCESS("\nDone.")) diff --git a/rodatraden/management/commands/generate_course_occasions.py b/rodatraden/management/commands/generate_course_occasions.py index af53de1..37b7364 100644 --- a/rodatraden/management/commands/generate_course_occasions.py +++ b/rodatraden/management/commands/generate_course_occasions.py @@ -12,7 +12,7 @@ from django.core.management.base import BaseCommand -from rodatraden.models import AcademicYear, Course, CourseOccasion +from rodatraden.models import Course, CourseOccasion, academic_year_title class Command(BaseCommand): @@ -47,14 +47,7 @@ def handle(self, *args, **options): 'Use --apply to write.\n' )) - try: - academic_year = AcademicYear.objects.get(year=year) - except AcademicYear.DoesNotExist: - self.stdout.write(self.style.ERROR( - f'AcademicYear with year={year} does not exist. ' - f'Create it first in the admin.' - )) - return + title = academic_year_title(year) courses = Course.objects.all().order_by('title') if course_id: @@ -73,7 +66,7 @@ def handle(self, *args, **options): for segment in segments: exists = CourseOccasion.objects.filter( course=course, - academic_year=academic_year, + year=year, time_period=segment.time_period, ).exists() @@ -83,7 +76,7 @@ def handle(self, *args, **options): self.stdout.write( f' {"CREATE" if apply else "WOULD CREATE"} ' - f'{course.title} — {academic_year.title} ' + f'{course.title} — {title} ' f'{segment.time_period.title} ' f'({segment.weeks} weeks)' ) @@ -91,7 +84,7 @@ def handle(self, *args, **options): if apply: CourseOccasion.objects.create( course=course, - academic_year=academic_year, + year=year, time_period=segment.time_period, weeks=segment.weeks, official=True, diff --git a/rodatraden/management/commands/infer_course_scheduling.py b/rodatraden/management/commands/infer_course_scheduling.py index 3ca4bfc..081d8b5 100644 --- a/rodatraden/management/commands/infer_course_scheduling.py +++ b/rodatraden/management/commands/infer_course_scheduling.py @@ -75,7 +75,7 @@ def handle(self, *args, **options): occasions = CourseOccasion.objects.filter( course=course - ).select_related('academic_year', 'time_period') + ).select_related('time_period') if not occasions.exists(): stats['skipped_no_occasions'] += 1 @@ -120,7 +120,7 @@ def _infer_segments(self, course, occasions): 'years': [], 'weeks': [], } - by_period[pid]['years'].append(occ.academic_year.year) + by_period[pid]['years'].append(occ.year) by_period[pid]['weeks'].append(occ.weeks) segments = [] diff --git a/rodatraden/management/commands/validate_course_schedule_parity.py b/rodatraden/management/commands/validate_course_schedule_parity.py index 8ff67e0..ba9db73 100644 --- a/rodatraden/management/commands/validate_course_schedule_parity.py +++ b/rodatraden/management/commands/validate_course_schedule_parity.py @@ -103,9 +103,9 @@ def _get_existing_tuples(self, course): """Return set of (year, period_title) from actual CourseOccasion rows.""" occasions = CourseOccasion.objects.filter( course=course - ).select_related('academic_year', 'time_period') + ).select_related('time_period') return { - (occ.academic_year.year, occ.time_period.title) + (occ.year, occ.time_period.title) for occ in occasions } diff --git a/rodatraden/models.py b/rodatraden/models.py index e8527a4..29b8242 100644 --- a/rodatraden/models.py +++ b/rodatraden/models.py @@ -10,6 +10,14 @@ User = get_user_model() +def academic_year_title(year: int) -> str: + """Return the conventional title string for an academic year. + + Example: 2011 → "11/12", 2020 → "20/21". + """ + return f"{str(year)[2:]}/{str(year + 1)[2:]}" + + def get_unique_slug(to_slug, model): """Generate unique slug for insert in model. @@ -172,26 +180,8 @@ class Meta: verbose_name_plural = 'Nivåer' -class AcademicYear(models.Model): - """'Akademiska perioder' to which all courses is associated with. - - For example, year 2018 is associated to period 18/19. - """ - - title = models.CharField(max_length=250) - year = models.IntegerField() - - created_at = models.DateTimeField(auto_now_add=True, editable=False, - null=False, blank=False) - updated_at = models.DateTimeField(auto_now=True, editable=False, null=False, - blank=False) - - def __str__(self): - return self.title - - class Meta: - verbose_name = 'Akademiskt år' - verbose_name_plural = 'Akademiska år' +# AcademicYear model removed — CourseOccasion.year is now a plain IntegerField +# and the display title (e.g. "20/21") is computed by academic_year_title(). class TimePeriod(models.Model): @@ -654,8 +644,8 @@ class CourseOccasion(models.Model): verbose_name='Kontaktadress') course = models.ForeignKey(Course, on_delete=models.CASCADE, verbose_name='Kurs') - academic_year = models.ForeignKey(AcademicYear, on_delete=models.CASCADE, - verbose_name='År') + # Plain integer — no FK lookup needed. Title derived via academic_year_title(). + year = models.IntegerField(verbose_name='År') time_period = models.ForeignKey(TimePeriod, on_delete=models.CASCADE, verbose_name='Läsperiod') slug = models.SlugField(max_length=100, unique=True, editable=False) @@ -672,7 +662,7 @@ class CourseOccasion(models.Model): def __str__(self): - return self.course.title + ' - ' + str(self.academic_year.year) + return self.course.title + ' - ' + str(self.year) def save(self, *args, **kwargs): @@ -699,7 +689,7 @@ def save(self, *args, **kwargs): def get_absolute_url(self): return reverse('courseoccasion-detail', kwargs={'year': - self.academic_year.year, 'slug': self.slug}) + self.year, 'slug': self.slug}) def as_json(self): @@ -710,7 +700,7 @@ def as_json(self): prerequisites_json = [prerequisite.as_json() for prerequisite in prerequisites] return dict( - year=self.academic_year.year, + year=self.year, start=self.time_period.week, title=self.course.title, ects=self.course.ects, @@ -739,6 +729,11 @@ def start_weeks_into_period(self): return get_start_weeks_into_period(self.time_period.week) + @property + def year_title(self): + """Computed display title, e.g. 2020 → '20/21'.""" + return academic_year_title(self.year) + def category_ects(self, category_sum): """Pass through. diff --git a/rodatraden/rodatraden_modules/functions.py b/rodatraden/rodatraden_modules/functions.py index 726acfe..e7351ec 100644 --- a/rodatraden/rodatraden_modules/functions.py +++ b/rodatraden/rodatraden_modules/functions.py @@ -26,7 +26,7 @@ def import_course_occasions(start_year: int, imported_block: Block): # Get all courseoccasions from selected block course_occasions = imported_block.courseoccasions.all().order_by( - 'academic_year__year', 'time_period__week' + 'year', 'time_period__week' ) # Difference in years from new block to the import block @@ -35,13 +35,13 @@ def import_course_occasions(start_year: int, imported_block: Block): # Create new courseoccasions new_course_occasions: list[CourseOccasion] = [] for course_occasion in course_occasions: - new_year = course_occasion.academic_year.year + year_diff + new_year = course_occasion.year + year_diff # Get the new course occasion. Just skip if something bad happens, # like if it doesn't exist for the new year. try: new_course_occasion = CourseOccasion.objects.get( course = course_occasion.course, - academic_year__year = new_year, + year = new_year, time_period__week = course_occasion.time_period.week ) new_course_occasions.append(new_course_occasion) diff --git a/rodatraden/tables.py b/rodatraden/tables.py index e660e6c..9afb1cc 100644 --- a/rodatraden/tables.py +++ b/rodatraden/tables.py @@ -41,8 +41,8 @@ class CourseOccasionTable(tables.Table): # Need to fetch url from courseoccasion model course = tables.Column(linkify=lambda record: record.get_absolute_url()) - academic_year = tables.Column(accessor="academic_year__year", - verbose_name="År") + # Year is now a direct IntegerField on CourseOccasion. + year = tables.Column(accessor="year", verbose_name="År") time_period = tables.Column(accessor="time_period__title", verbose_name="Läsperiod") @@ -72,9 +72,9 @@ class Meta: model = CourseOccasion # Style template template_name = 'django_tables2/bootstrap4.html' - fields = ('official', 'course', 'academic_year', 'time_period', 'weeks', + fields = ('official', 'course', 'year', 'time_period', 'weeks', 'course__ects', 'categories') - order_by = ('course', '-academic_year', ) + order_by = ('course', '-year', ) class ExamTable(tables.Table): diff --git a/rodatraden/templates/rodatraden/courseoccasion/courseoccasion_confirm_delete.html b/rodatraden/templates/rodatraden/courseoccasion/courseoccasion_confirm_delete.html index 6546c80..4c38839 100644 --- a/rodatraden/templates/rodatraden/courseoccasion/courseoccasion_confirm_delete.html +++ b/rodatraden/templates/rodatraden/courseoccasion/courseoccasion_confirm_delete.html @@ -12,7 +12,7 @@
Är du säker på att du vill radera kurstillfället - {{ courseoccasion.course.title }} - {{ courseoccasion.academic_year.year }}?
+ {{ courseoccasion.course.title }} - {{ courseoccasion.year }}?