Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,16 @@ This setup has not been fully tested on Windows, so some adjustment is probably
### Clean install
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)
After this navigate to the admin interface. Here we will have to add entries like "institutioner" "Nivåer" and "Spår".
- 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
- Tidsperioder : This is when during the year a course starts. For example week 0 for "läsperiod 1", week 10 for "läsperiod 2" Week 0 is the beginning of time academic year. If a course starts 3 weeks into period 2, then specify week 13.
Course periods are now entered directly as start week values in forms
(LP1=0, LP2=10, LP3=20, LP4=30, LP5=40). If a course starts 3 weeks into
period 2, set start week to 13.

Academic years (e.g. "20/21") are computed automatically — no database setup
needed. Year dropdowns in forms show the current year ± 10.
Comment on lines +177 to +186

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Call out the mandatory upgrade script in this section.

This new setup guidance explains the post-refactor data model, but it still leaves upgrade readers one scroll away from the generic makemigrations instructions above. For this PR, that is the wrong path: existing environments need python upgrade_remove_incremental_years.py, otherwise the migration flow is easy to run incorrectly.

📝 Suggested wording
 After this navigate to the admin interface. Here we will have to add entries like "institutioner" "Nivåer" and "Spår".
 - 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
+
+> **Upgrade note:** Existing installations must run
+> `python upgrade_remove_incremental_years.py` in each environment for this
+> change set. Do not rely on plain `python manage.py makemigrations` when
+> upgrading from the old AcademicYear/TimePeriod model.
 
 Course periods are now entered directly as start week values in forms
 (LP1=0, LP2=10, LP3=20, LP4=30, LP5=40). If a course starts 3 weeks into
 period 2, set start week to 13.
🧰 Tools
🪛 LanguageTool

[style] ~179-~179: In contexts where ‘if’ is followed by ‘or’, using ‘whether’ may be more appropriate (and formal).
Context: ...the course can be / is taken - Nivåer : If it's an advanced course or not (grundlä...

(IF_WHETHER)


[grammar] ~180-~180: Ensure spelling is correct
Context: ...Spår : The different tracks that can be choosen. Make a profile before on the main site...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 177 - 186, This section documents the post-refactor
data model but omits a critical, mandatory upgrade step; update the paragraph
after the admin/setup instructions to explicitly call out and instruct users to
run the upgrade script `python upgrade_remove_incremental_years.py` before
running migrations (i.e., before following the generic
`makemigrations`/`migrate` flow), making clear it is required for existing
environments and where it should be executed in the setup sequence.


After after creating these, new courses can be added to the default site

Expand Down
2 changes: 0 additions & 2 deletions rodatraden/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
Department,
Level,
Track,
AcademicYear,
TimePeriod,
ISPTemplate,
CourseScheduleSegment,
Exam,
Expand Down
89 changes: 60 additions & 29 deletions rodatraden/filters.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import datetime
import django_filters
from .models import (
Course, Category, Level, Department, Track, AcademicYear, TimePeriod
Course, Category, Level, Department, Profile, Track,
CourseOccasion, academic_year_title, YEAR_RANGE_OFFSET
)


def _courseoccasion_year_choices(extra_years=YEAR_RANGE_OFFSET):
"""Build year choices from dynamic range + existing database values."""
current = datetime.date.today().year
years = set(range(current - extra_years, current + extra_years + 1))
years.update(
CourseOccasion.objects.exclude(year__isnull=True).values_list('year', flat=True)
)

return [('', 'Läsår')] + [(y, academic_year_title(y)) for y in sorted(years)]

class CourseFilter(django_filters.FilterSet):
"""Filter settings for the list of courses.

Expand Down Expand Up @@ -37,40 +50,58 @@ def filter_by_course(self, queryset, name, value):
queryset=Track.objects.all().order_by('title'),
empty_label='Profil', field_name='tracks'
)
academic_year = django_filters.ModelChoiceFilter(
queryset=AcademicYear.objects.all().order_by('-year'),
empty_label='Läsår', method='filter_by_academic_year'
)
time_period = django_filters.ModelChoiceFilter(
queryset=TimePeriod.objects.all().order_by('week'),
empty_label='Läsperiod', method='filter_by_time_period'
)

def filter_by_academic_year(self, queryset, name, value):
"""Filter courses that have an official occasion in the given academic year."""
if value:
return queryset.filter(
courseoccasion__academic_year=value,
courseoccasion__official=True
).distinct()
return queryset

def filter_by_time_period(self, queryset, name, value):
"""Filter courses that have an official occasion in the given time period."""
if value:
return queryset.filter(
courseoccasion__time_period=value,
courseoccasion__official=True
).distinct()
return queryset

class Meta:
model = Course
fields = ['title', 'categories', 'profile', 'level', 'department',
'academic_year', 'time_period']
fields = ['title', 'categories', 'profile', 'level', 'department']

@property
def qs(self):
# Sort by title in ascending order if no sort order is specified.
sort_order = self.data.get('sort_order', 'title')
return super().qs.order_by(sort_order)


class CourseOccasionFilter(django_filters.FilterSet):
"""Filter settings for the list of course occasions."""

# A lot of the filters refer to the course that the courseoccasion is
# connected to, hence the 'field_name' argument
title = django_filters.ModelChoiceFilter(
queryset=Course.objects.all().order_by('title'),
empty_label='Kursnamn', to_field_name='id', field_name='course'
)
# Year is now a plain IntegerField — use ChoiceFilter with dynamic choices.
year = django_filters.ChoiceFilter(
choices=_courseoccasion_year_choices,
empty_label=None, # we include the empty option in choices above
field_name='year'
)
time_period = django_filters.ChoiceFilter(
choices=lambda: [('', 'Läsperiod')] + [
(i * 10, f'LP{i}') for i in range(1, 6)
],
empty_label=None,
field_name='start',
label='Läsperiod',
)
Comment on lines +80 to 87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter start by LP window, not exact week value.

The new start field stores offsets inside the period (13 means LP2 + 3 weeks), so an exact field_name='start' filter only finds occasions that start on week 10/20/30/40. The hardcoded values are also off by one period: LP1 should map to 0, and LP5 should map to 40.

Suggested fix
     time_period = django_filters.ChoiceFilter(
             choices=lambda: [('', 'Läsperiod')] + [
-                (i * 10, f'LP{i}') for i in range(1, 6)
+                ((i - 1) * 10, f'LP{i}') for i in range(1, 6)
             ],
             empty_label=None,
-            field_name='start',
+            method='filter_time_period',
             label='Läsperiod',
     )
+
+    def filter_time_period(self, queryset, name, value):
+        if value in (None, ''):
+            return queryset
+        period_start = int(value)
+        return queryset.filter(
+            start__gte=period_start,
+            start__lt=period_start + 10,
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
time_period = django_filters.ChoiceFilter(
choices=lambda: [('', 'Läsperiod')] + [
(i * 10, f'LP{i}') for i in range(1, 6)
],
empty_label=None,
field_name='start',
label='Läsperiod',
)
time_period = django_filters.ChoiceFilter(
choices=lambda: [('', 'Läsperiod')] + [
((i - 1) * 10, f'LP{i}') for i in range(1, 6)
],
empty_label=None,
method='filter_time_period',
label='Läsperiod',
)
def filter_time_period(self, queryset, name, value):
if value in (None, ''):
return queryset
period_start = int(value)
return queryset.filter(
start__gte=period_start,
start__lt=period_start + 10,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rodatraden/filters.py` around lines 81 - 88, The filter is currently matching
exact start weeks and uses wrong LP values; change the ChoiceFilter named
time_period to emit LP base offsets as (i-1)*10 (so LP1 -> 0, LP5 -> 40) and
switch it to a method filter (add method='filter_time_period') instead of
field_name='start' so you can return occasions whose start falls inside the LP
window (start between base and base+9). Implement a FilterSet method
filter_time_period(self, queryset, name, value) that parses the chosen base
offset, computes base=int(value) and filters queryset.filter(start__gte=base,
start__lte=base+9) (or start__lt=base+10) and leave the human label choices as
before.

categories = django_filters.ModelChoiceFilter(
queryset=Category.objects.all().order_by('title'),
field_name='course__categories',
empty_label='Kategori'
)
department = django_filters.ModelChoiceFilter(
queryset=Department.objects.all().order_by('title'),
field_name='course__department',
empty_label='Institution'
)
official = django_filters.ChoiceFilter(
choices=((True, 'Godkänd'), (False, 'Ej godkänd')),
empty_label='Status'
)

class Meta:
model = CourseOccasion
fields = ['title', 'year', 'categories', 'department',
'official'
]
154 changes: 59 additions & 95 deletions rodatraden/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from .models import (
Course, Block, CourseOccasion, CourseScheduleSegment, Category,
CategoryCourse, Prerequisite, Profile,
AcademicYear, TimePeriod, CategoryExam, Exam, Report, PrivateCourse,
PrivateCourseCategory, User
CategoryExam, Exam, Report, PrivateCourse,
PrivateCourseCategory, User, academic_year_title, YEAR_RANGE_OFFSET
)
from .rodatraden_modules.mixins import (
CategoryFormMixin, PrerequisiteFormMixin, SaveAndImportBlockMixin
Expand All @@ -16,6 +16,24 @@
from bootstrap_modal_forms.forms import BSModalForm, BSModalModelForm


def _dynamic_year_choices(extra_years=YEAR_RANGE_OFFSET, include_years=None):
"""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
years = set(range(current - extra_years, current + extra_years + 1))
for y in include_years or []:
if y is not None:
years.add(int(y))

return [
(y, academic_year_title(y))
for y in sorted(years)
]


class UpdateUserForm(forms.ModelForm):
"""Form for updating user"""

Expand Down Expand Up @@ -111,8 +129,10 @@ 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(
include_years=[self.instance.year] if self.instance.pk else None
)
self.fields['year'] = forms.ChoiceField(choices=year_choices,
initial=datetime.datetime.now().year)
# Always save to current user
Expand Down Expand Up @@ -150,8 +170,6 @@ class Meta:
class CourseScheduleSegmentForm(BSModalModelForm):
"""Form for course schedule segments."""

start_week = StartWeekField(label='Start')

blacklisted_years = forms.MultipleChoiceField(
choices=[],
required=False,
Expand All @@ -166,10 +184,11 @@ 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).
include_years = [self.instance.start_year, self.instance.end_year] if self.instance.pk else None
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(include_years=include_years)
]
self.fields['start_year'] = forms.ChoiceField(
choices=year_choices,
Expand All @@ -183,6 +202,9 @@ def __init__(self, *args, **kwargs):

# Blacklisted years: same year choices as multi-select
self.fields['blacklisted_years'].choices = year_choices

self.fields['start'] = StartWeekField(label='Läsperiod')

# Pre-fill for existing instances
if self.instance.pk:
self.initial['start_year'] = str(self.instance.start_year)
Expand All @@ -192,12 +214,10 @@ def __init__(self, *args, **kwargs):
self.initial['blacklisted_years'] = [
str(y) for y in self.instance.blacklisted_years
]
self.fields['start_week'].initial = (
self.instance.time_period.week + (self.instance.start_offset or 0)
)
self.initial['start'] = self.instance.start

self.order_fields([
'start_week',
'start',
'frequency',
'start_year',
'end_year',
Expand All @@ -215,35 +235,10 @@ def clean_end_year(self):
def clean_blacklisted_years(self):
return [int(y) for y in self.cleaned_data.get('blacklisted_years', [])]

def save(self, commit=True):
segment = super().save(commit=False)
start_week = self.cleaned_data['start_week']

weeks_in_period = 10
period_number = start_week // weeks_in_period + 1
base_period_week = (period_number - 1) * weeks_in_period
start_offset = start_week % weeks_in_period

time_period = TimePeriod.objects.filter(week=base_period_week).first()
if time_period is None:
time_period = TimePeriod.objects.create(
week=base_period_week,
title=f'LP{period_number}',
)

segment.time_period = time_period
segment.start_offset = start_offset

if commit:
segment.save()
self.save_m2m()

return segment

class Meta:
model = CourseScheduleSegment
fields = ['course', 'frequency',
'start_year', 'end_year', 'weeks', 'blacklisted_years']
fields = ['course', 'start', 'frequency', 'start_year',
'end_year', 'weeks', 'blacklisted_years']
widgets = {
'course': forms.HiddenInput(),
}
Expand All @@ -252,80 +247,48 @@ class Meta:
class CourseOccasionForm(BSModalModelForm):
"""Form for course occasions."""

start_week = StartWeekField(label='Start')

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(
include_years=[self.instance.year] if self.instance.pk else None
)
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['start_week'].initial = courseocc_cpy.time_period.week
self.fields['year'].initial = courseocc_cpy.year
self.fields['start'].initial = courseocc_cpy.start
self.fields['weeks'].initial = courseocc_cpy.weeks
self.fields['note'].initial = courseocc_cpy.note
self.fields['contact_name'].initial = courseocc_cpy.contact_name
self.fields['contact_email'].initial = courseocc_cpy.contact_email
self.fields['official'].initial = courseocc_cpy.official

# Populate start week when editing an existing occasion.
if self.instance.pk:
self.fields['start_week'].initial = self.instance.time_period.week

self.fields['course'].widget.attrs['class'] = \
'course-list-filter-courseocc-create'

self.order_fields([
'course',
'academic_year',
'start_week',
'weeks',
'note',
'contact_name',
'contact_email',
'official',
])

@staticmethod
def _format_time_period_title(start_week: int) -> str:
weeks_in_period = 10
period_number = start_week // weeks_in_period + 1
period_start_offset = start_week % weeks_in_period

result = f'LP{period_number}'
if period_start_offset:
postfix = 'vecka' if period_start_offset == 1 else 'veckor'
result += f' - {period_start_offset} {postfix} in'
return result

def save(self, commit=True):
courseoccasion = super().save(commit=False)
start_week = self.cleaned_data['start_week']

# Resolve existing period by week, or create one if it is missing.
time_period = TimePeriod.objects.filter(week=start_week).first()
if time_period is None:
time_period = TimePeriod.objects.create(
week=start_week,
title=self._format_time_period_title(start_week),
)
self.fields['start'] = StartWeekField(label='Läsperiod')
Comment on lines +253 to +280

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don't overwrite the copied start initial.

In the copy flow, Line 270 sets the initial value on the current start field, but Line 280 immediately replaces that field with a new StartWeekField. The copied occasion therefore loses its start period and falls back to the default widget state.

💡 Minimal fix
+        self.fields['start'] = StartWeekField(label='Läsperiod')
+
         # 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['year'].initial = courseocc_cpy.year
-            self.fields['start'].initial = courseocc_cpy.start
+            self.initial['start'] = courseocc_cpy.start
             self.fields['weeks'].initial = courseocc_cpy.weeks
             self.fields['note'].initial = courseocc_cpy.note
             self.fields['contact_name'].initial = courseocc_cpy.contact_name
             self.fields['contact_email'].initial = courseocc_cpy.contact_email
             self.fields['official'].initial = courseocc_cpy.official
@@
-        self.fields['start'] = StartWeekField(label='Läsperiod')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Year is now a plain IntegerField — present as a dropdown with
# dynamically computed year choices (current year ± 10).
year_choices = _dynamic_year_choices(
include_years=[self.instance.year] if self.instance.pk else None
)
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['start_week'].initial = courseocc_cpy.time_period.week
self.fields['year'].initial = courseocc_cpy.year
self.fields['start'].initial = courseocc_cpy.start
self.fields['weeks'].initial = courseocc_cpy.weeks
self.fields['note'].initial = courseocc_cpy.note
self.fields['contact_name'].initial = courseocc_cpy.contact_name
self.fields['contact_email'].initial = courseocc_cpy.contact_email
self.fields['official'].initial = courseocc_cpy.official
# Populate start week when editing an existing occasion.
if self.instance.pk:
self.fields['start_week'].initial = self.instance.time_period.week
self.fields['course'].widget.attrs['class'] = \
'course-list-filter-courseocc-create'
self.order_fields([
'course',
'academic_year',
'start_week',
'weeks',
'note',
'contact_name',
'contact_email',
'official',
])
@staticmethod
def _format_time_period_title(start_week: int) -> str:
weeks_in_period = 10
period_number = start_week // weeks_in_period + 1
period_start_offset = start_week % weeks_in_period
result = f'LP{period_number}'
if period_start_offset:
postfix = 'vecka' if period_start_offset == 1 else 'veckor'
result += f' - {period_start_offset} {postfix} in'
return result
def save(self, commit=True):
courseoccasion = super().save(commit=False)
start_week = self.cleaned_data['start_week']
# Resolve existing period by week, or create one if it is missing.
time_period = TimePeriod.objects.filter(week=start_week).first()
if time_period is None:
time_period = TimePeriod.objects.create(
week=start_week,
title=self._format_time_period_title(start_week),
)
self.fields['start'] = StartWeekField(label='Läsperiod')
# Year is now a plain IntegerField — present as a dropdown with
# dynamically computed year choices (current year ± 10).
year_choices = _dynamic_year_choices(
include_years=[self.instance.year] if self.instance.pk else None
)
self.fields['year'] = forms.ChoiceField(
choices=year_choices,
initial=datetime.datetime.now().year,
label='År',
)
self.fields['start'] = StartWeekField(label='Läsperiod')
# 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['year'].initial = courseocc_cpy.year
self.initial['start'] = courseocc_cpy.start
self.fields['weeks'].initial = courseocc_cpy.weeks
self.fields['note'].initial = courseocc_cpy.note
self.fields['contact_name'].initial = courseocc_cpy.contact_name
self.fields['contact_email'].initial = courseocc_cpy.contact_email
self.fields['official'].initial = courseocc_cpy.official
self.fields['course'].widget.attrs['class'] = \
'course-list-filter-courseocc-create'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rodatraden/forms.py` around lines 253 - 280, The copied CourseOccasion's
start initial is being lost because you assign self.fields['start'].initial from
courseocc_cpy, then immediately replace that field with a new StartWeekField at
the end; either instantiate the StartWeekField before you apply the copied
values or (preferably) move the line self.fields['start'] =
StartWeekField(label='Läsperiod') to above the "if 'courseocc' in
self.request.GET" block so the initial on self.fields['start'] persists, or
alternatively set self.fields['start'].initial = courseocc_cpy.start after
creating the StartWeekField; update the code around StartWeekField and the copy
block accordingly (references: StartWeekField and self.fields['start'], and the
courseocc_cpy assignment).


courseoccasion.time_period = time_period

if commit:
courseoccasion.save()
self.save_m2m()

return courseoccasion
def clean_year(self):
"""Coerce the ChoiceField string back to int for the IntegerField."""
try:
return int(self.cleaned_data['year'])
except (ValueError, TypeError):
raise forms.ValidationError('Ogiltigt år.')

class Meta:
model = CourseOccasion
fields = ['course', 'academic_year', 'weeks',
fields = ['course', 'year', 'start', 'weeks',
'note', 'contact_name', 'contact_email', 'official']


Expand All @@ -344,9 +307,10 @@ 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(
include_years=[self.instance.start_year] if self.instance.pk else None
)

# Use can import form all public blocks published in a track and all
# their own blocks.
Expand Down
Loading
Loading