feat: period-scoped holiday management, archive lifecycle, and docs/release sync

- add period-scoped holiday architecture end-to-end
	- model: scope `SchoolHoliday` to `academic_period_id`
	- migrations: add holiday-period scoping, academic-period archive lifecycle, and merge migration head
	- API: extend holidays with manual CRUD, period validation, duplicate prevention, and overlap merge/conflict handling
	- recurrence: regenerate holiday exceptions using period-scoped holiday sets

- improve frontend settings and holiday workflows
	- bind holiday import/list/manual CRUD to selected academic period
	- show detailed import outcomes (inserted/updated/merged/skipped/conflicts)
	- fix file-picker UX (visible selected filename)
	- align settings controls/dialogs with defined frontend design rules
	- scope appointments/dashboard holiday loading to active period
	- add shared date formatting utility

- strengthen academic period lifecycle handling
	- add archive/restore/delete flow and backend validations/blocker checks
	- extend API client support for lifecycle operations

- release/docs updates and cleanup
	- bump user-facing version to `2026.1.0-alpha.15` with new changelog entry
	- add tech changelog entry for alpha.15 backend changes
	- refactor README to concise index and archive historical implementation docs
	- fix Copilot instruction link diagnostics via local `.github` design-rules reference
This commit is contained in:
2026-03-31 12:25:55 +00:00
parent 2580aa5e0d
commit b5f5f30005
23 changed files with 2940 additions and 897 deletions

View File

@@ -73,15 +73,22 @@ class AcademicPeriod(Base):
nullable=False, default=AcademicPeriodType.schuljahr)
# nur eine aktive Periode zur Zeit
is_active = Column(Boolean, default=False, nullable=False)
# Archive lifecycle fields
is_archived = Column(Boolean, default=False, nullable=False, index=True)
archived_at = Column(TIMESTAMP(timezone=True), nullable=True)
archived_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_at = Column(TIMESTAMP(timezone=True),
server_default=func.current_timestamp())
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
), onupdate=func.current_timestamp())
# Constraint: nur eine aktive Periode zur Zeit
# Constraint: nur eine aktive Periode zur Zeit; name unique among non-archived periods
__table_args__ = (
Index('ix_academic_periods_active', 'is_active'),
UniqueConstraint('name', name='uq_academic_periods_name'),
Index('ix_academic_periods_archived', 'is_archived'),
# Unique constraint on active (non-archived) periods only is handled in code
# This index facilitates the query for checking uniqueness
Index('ix_academic_periods_name_not_archived', 'name', 'is_archived'),
)
def to_dict(self):
@@ -93,6 +100,9 @@ class AcademicPeriod(Base):
"end_date": self.end_date.isoformat() if self.end_date else None,
"period_type": self.period_type.value if self.period_type else None,
"is_active": self.is_active,
"is_archived": self.is_archived,
"archived_at": self.archived_at.isoformat() if self.archived_at else None,
"archived_by": self.archived_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@@ -276,6 +286,7 @@ class EventMedia(Base):
class SchoolHoliday(Base):
__tablename__ = 'school_holidays'
id = Column(Integer, primary_key=True, autoincrement=True)
academic_period_id = Column(Integer, ForeignKey('academic_periods.id', ondelete='SET NULL'), nullable=True, index=True)
name = Column(String(150), nullable=False)
start_date = Column(Date, nullable=False, index=True)
end_date = Column(Date, nullable=False, index=True)
@@ -284,14 +295,17 @@ class SchoolHoliday(Base):
imported_at = Column(TIMESTAMP(timezone=True),
server_default=func.current_timestamp())
academic_period = relationship("AcademicPeriod", foreign_keys=[academic_period_id])
__table_args__ = (
UniqueConstraint('name', 'start_date', 'end_date',
'region', name='uq_school_holidays_unique'),
'region', 'academic_period_id', name='uq_school_holidays_unique'),
)
def to_dict(self):
return {
"id": self.id,
"academic_period_id": self.academic_period_id,
"name": self.name,
"start_date": self.start_date.isoformat() if self.start_date else None,
"end_date": self.end_date.isoformat() if self.end_date else None,