diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8dee171..6c68e72 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,8 +24,9 @@ Use this as your shared context when proposing changes. Keep edits minimal and m - Screenshots: server-side folders `server/received_screenshots/` and `server/screenshots/`; Nginx exposes `/screenshots/{uuid}.jpg` via `server/wsgi.py` route. ## Data model highlights (see `models/models.py`) -- Enums: `EventType` (presentation, website, video, message, webuntis) and `MediaType` (file/website types). -- Tables: `clients`, `client_groups`, `events`, `event_media`, `users`. +- Enums: `EventType` (presentation, website, video, message, webuntis), `MediaType` (file/website types), and `AcademicPeriodType` (schuljahr, semester, trimester). +- Tables: `clients`, `client_groups`, `events`, `event_media`, `users`, `academic_periods`, `school_holidays`. +- Academic periods: `academic_periods` table supports educational institution cycles (school years, semesters). Events and media can be optionally linked via `academic_period_id` (nullable for backward compatibility). - Times are stored as timezone-aware; treat comparisons in UTC (see scheduler and routes/events). ## API patterns @@ -36,11 +37,19 @@ Use this as your shared context when proposing changes. Keep edits minimal and m - Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`. - Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC. - Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`. + - Academic periods: `server/routes/academic_periods.py` exposes: + - `GET /api/academic_periods` — list all periods + - `GET /api/academic_periods/active` — currently active period + - `POST /api/academic_periods/active` — set active period (deactivates others) + - `GET /api/academic_periods/for_date?date=YYYY-MM-DD` — period covering given date ## Frontend patterns (dashboard) - Vite React app; proxies `/api` and `/screenshots` to API in dev (`vite.config.ts`). - Uses Syncfusion components; Vite config pre-bundles specific packages to avoid alias issues. - Environment: `VITE_API_URL` provided at build/run; in dev compose, proxy handles `/api` so local fetches can use relative `/api/...` paths. + - Scheduler (appointments page): top bar includes Group and Academic Period selectors (Syncfusion DropDownList). Selecting a period calls `POST /api/academic_periods/active`, moves the calendar to today’s month/day within the period year, and refreshes a right-aligned indicator row showing: + - Holidays present in the current view (count) + - Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check) ## Local development - Compose: development is `docker-compose.yml` + `docker-compose.override.yml`. @@ -49,6 +58,7 @@ Use this as your shared context when proposing changes. Keep edits minimal and m - Mosquitto: allows anonymous in dev; WebSocket on :9001. - Common env vars: `DB_CONN`, `DB_USER`, `DB_PASSWORD`, `DB_HOST=db`, `DB_NAME`, `ENV`, `MQTT_USER`, `MQTT_PASSWORD`. - Alembic: prod compose runs `alembic ... upgrade head` and `server/init_defaults.py` before gunicorn. + - Use `server/init_academic_periods.py` to populate default Austrian school years after migration. ## Production - `docker-compose.prod.yml` uses prebuilt images (`ghcr.io/robbstarkaustria/*`). @@ -74,6 +84,7 @@ Use this as your shared context when proposing changes. Keep edits minimal and m 3) Manage `Session()` lifecycle, and 4) Return JSON-safe values (serialize enums and datetimes). - When extending media types, update `MediaType` and any logic in `eventmedia` and dashboard that depends on it. +- Academic periods: Events/media can be optionally associated with periods for educational organization. Only one period should be active at a time (`is_active=True`). ## Quick examples - Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients//description` in `routes/clients.py`. @@ -81,3 +92,10 @@ Use this as your shared context when proposing changes. Keep edits minimal and m - Listener heartbeat path: `infoscreen//heartbeat` → sets `clients.last_alive`. Questions or unclear areas? Tell us if you need: exact devcontainer debugging steps, stricter Alembic workflow, or a seed dataset beyond `init_defaults.py`. + +## Academic Periods System +- **Purpose**: Organize events and media by educational cycles (school years, semesters, trimesters). +- **Design**: Fully backward compatible - existing events/media continue to work without period assignment. +- **Usage**: New events/media can optionally reference `academic_period_id` for better organization and filtering. +- **Constraints**: Only one period can be active at a time; use `init_academic_periods.py` for Austrian school year setup. + - **UI Integration**: The dashboard highlights the currently selected period and whether a holiday plan exists within that date range. Holiday linkage currently uses date overlap with `school_holidays`; an explicit `academic_period_id` on `school_holidays` can be added later if tighter association is required. diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json index 3fda5e8..1f1a7e2 100644 --- a/dashboard/public/program-info.json +++ b/dashboard/public/program-info.json @@ -1,6 +1,6 @@ { "appName": "Infoscreen-Management", - "version": "2025.1.0-alpha.6", + "version": "2025.1.0-alpha.7", "copyright": "© 2025 Third-Age-Applications", "supportContact": "support@third-age-applications.com", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", @@ -30,6 +30,18 @@ "commitId": "8d1df7199cb7" }, "changelog": [ + { + "version": "2025.1.0-alpha.7", + "date": "2025-09-21", + "changes": [ + "🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompaktes Layout", + "✅ Anzeige: Abzeichen für vorhandenen Ferienplan + Zähler ‘Ferien im Blick’", + "🛠️ API: Endpunkte für akademische Perioden (list, active GET/POST, for_date)", + "📅 Scheduler: Standardmäßig keine Terminierung in Ferien; Block-Darstellung wie Ganztagesereignis; schwarze Textfarbe", + "📤 Ferien: Upload von TXT/CSV (headless TXT nutzt Spalten 2–4)", + "🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert" + ] + }, { "version": "2025.1.0-alpha.6", "date": "2025-09-20", diff --git a/dashboard/src/apiAcademicPeriods.ts b/dashboard/src/apiAcademicPeriods.ts new file mode 100644 index 0000000..3bf2ec5 --- /dev/null +++ b/dashboard/src/apiAcademicPeriods.ts @@ -0,0 +1,42 @@ +export type AcademicPeriod = { + id: number; + name: string; + display_name?: string | null; + start_date: string; // YYYY-MM-DD + end_date: string; // YYYY-MM-DD + period_type: 'schuljahr' | 'semester' | 'trimester'; + is_active: boolean; +}; + +async function api(url: string, init?: RequestInit): Promise { + const res = await fetch(url, { credentials: 'include', ...init }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +export async function getAcademicPeriodForDate(date: Date): Promise { + const iso = date.toISOString().slice(0, 10); + const { period } = await api<{ period: AcademicPeriod | null }>( + `/api/academic_periods/for_date?date=${iso}` + ); + return period ?? null; +} + +export async function listAcademicPeriods(): Promise { + const { periods } = await api<{ periods: AcademicPeriod[] }>(`/api/academic_periods`); + return Array.isArray(periods) ? periods : []; +} + +export async function getActiveAcademicPeriod(): Promise { + const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`); + return period ?? null; +} + +export async function setActiveAcademicPeriod(id: number): Promise { + const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/active`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + return period; +} diff --git a/dashboard/src/appointments.tsx b/dashboard/src/appointments.tsx index 9ae0b7b..8f3ac47 100644 --- a/dashboard/src/appointments.tsx +++ b/dashboard/src/appointments.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ScheduleComponent, Day, @@ -23,7 +23,21 @@ import { getGroupColor } from './groupColors'; import { deleteEvent } from './apiEvents'; import CustomEventModal from './components/CustomEventModal'; import { fetchMediaById } from './apiClients'; -import { Presentation, Globe, Video, MessageSquare, School } from 'lucide-react'; +import { listHolidays, type Holiday } from './apiHolidays'; +import { + getAcademicPeriodForDate, + listAcademicPeriods, + setActiveAcademicPeriod, +} from './apiAcademicPeriods'; +import { + Presentation, + Globe, + Video, + MessageSquare, + School, + CheckCircle, + AlertCircle, +} from 'lucide-react'; import { renderToStaticMarkup } from 'react-dom/server'; import caGregorian from './cldr/ca-gregorian.json'; import numbers from './cldr/numbers.json'; @@ -43,6 +57,8 @@ type Event = { StartTime: Date; EndTime: Date; IsAllDay: boolean; + IsBlock?: boolean; // Syncfusion block appointment + isHoliday?: boolean; // marker for styling/logic MediaId?: string | number; SlideshowInterval?: number; WebsiteUrl?: string; @@ -123,15 +139,15 @@ const eventTemplate = (event: Event) => { return (
-
+
{IconComponent && ( - + )} {event.Subject}
-
{timeString}
+
{timeString}
); }; @@ -140,11 +156,20 @@ const Appointments: React.FC = () => { const [groups, setGroups] = useState([]); const [selectedGroupId, setSelectedGroupId] = useState(null); const [events, setEvents] = useState([]); + const [holidays, setHolidays] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [modalInitialData, setModalInitialData] = useState({}); const [schedulerKey, setSchedulerKey] = useState(0); const [editMode, setEditMode] = useState(false); // NEU: Editiermodus const [showInactive, setShowInactive] = React.useState(true); + const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false); + const [showHolidayList, setShowHolidayList] = React.useState(true); + const scheduleRef = React.useRef(null); + const [holidaysInView, setHolidaysInView] = React.useState(0); + const [schoolYearLabel, setSchoolYearLabel] = React.useState(''); + const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState(false); + const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]); + const [activePeriodId, setActivePeriodId] = React.useState(null); // Gruppen laden useEffect(() => { @@ -158,6 +183,24 @@ const Appointments: React.FC = () => { .catch(console.error); }, []); + // Holidays laden + useEffect(() => { + listHolidays() + .then(res => setHolidays(res.holidays || [])) + .catch(err => console.error('Ferien laden fehlgeschlagen:', err)); + }, []); + + // Perioden laden (Dropdown) + useEffect(() => { + listAcademicPeriods() + .then(all => { + setPeriods(all.map(p => ({ id: p.id, label: p.display_name || p.name }))); + const active = all.find(p => p.is_active); + setActivePeriodId(active ? active.id : null); + }) + .catch(err => console.error('Akademische Perioden laden fehlgeschlagen:', err)); + }, []); + // fetchAndSetEvents als useCallback definieren, damit die Dependency korrekt ist: const fetchAndSetEvents = React.useCallback(async () => { if (!selectedGroupId) { @@ -178,7 +221,7 @@ const Appointments: React.FC = () => { })); setEvents(mapped); } catch (err) { - console.error(err); + console.error('Fehler beim Laden der Termine:', err); } }, [selectedGroupId, showInactive]); @@ -191,11 +234,168 @@ const Appointments: React.FC = () => { } }, [selectedGroupId, showInactive, fetchAndSetEvents]); + // Helper: prüfe, ob Zeitraum einen Feiertag/Ferienbereich schneidet + const isWithinHolidayRange = React.useCallback( + (start: Date, end: Date) => { + // normalisiere Endzeit minimal (Syncfusion nutzt exklusive Enden in einigen Fällen) + const adjEnd = new Date(end); + // keine Änderung nötig – unsere eigenen Events sind präzise + for (const h of holidays) { + // Holiday dates are strings YYYY-MM-DD (local date) + const hs = new Date(h.start_date + 'T00:00:00'); + const he = new Date(h.end_date + 'T23:59:59'); + if ( + (start >= hs && start <= he) || + (adjEnd >= hs && adjEnd <= he) || + (start <= hs && adjEnd >= he) + ) { + return true; + } + } + return false; + }, + [holidays] + ); + + // Baue Holiday-Anzeige-Events und Block-Events + const holidayDisplayEvents: Event[] = useMemo(() => { + if (!showHolidayList) return []; + const out: Event[] = []; + for (const h of holidays) { + const start = new Date(h.start_date + 'T00:00:00'); + const end = new Date(h.end_date + 'T23:59:59'); + out.push({ + Id: `holiday-${h.id}-display`, + Subject: h.name, + StartTime: start, + EndTime: end, + IsAllDay: true, + isHoliday: true, + }); + } + return out; + }, [holidays, showHolidayList]); + + const holidayBlockEvents: Event[] = useMemo(() => { + if (allowScheduleOnHolidays) return []; + const out: Event[] = []; + for (const h of holidays) { + const start = new Date(h.start_date + 'T00:00:00'); + const end = new Date(h.end_date + 'T23:59:59'); + out.push({ + Id: `holiday-${h.id}-block`, + Subject: h.name, + StartTime: start, + EndTime: end, + IsAllDay: true, + IsBlock: true, + isHoliday: true, + }); + } + return out; + }, [holidays, allowScheduleOnHolidays]); + + const dataSource = useMemo(() => { + return [...events, ...holidayDisplayEvents, ...holidayBlockEvents]; + }, [events, holidayDisplayEvents, holidayBlockEvents]); + + // Aktive akademische Periode für Datum aus dem Backend ermitteln + const refreshAcademicPeriodFor = React.useCallback( + async (baseDate: Date) => { + try { + const p = await getAcademicPeriodForDate(baseDate); + if (!p) { + setSchoolYearLabel(''); + setHasSchoolYearPlan(false); + return; + } + // Anzeige: bevorzugt display_name, sonst name + const label = p.display_name ? p.display_name : p.name; + setSchoolYearLabel(label); + // Existiert ein Ferienplan innerhalb der Periode? + const start = new Date(p.start_date + 'T00:00:00'); + const end = new Date(p.end_date + 'T23:59:59'); + let exists = false; + for (const h of holidays) { + const hs = new Date(h.start_date + 'T00:00:00'); + const he = new Date(h.end_date + 'T23:59:59'); + if (hs <= end && he >= start) { + exists = true; + break; + } + } + setHasSchoolYearPlan(exists); + } catch (e) { + console.error('Akademische Periode laden fehlgeschlagen:', e); + setSchoolYearLabel(''); + setHasSchoolYearPlan(false); + } + }, + [holidays] + ); + + // Anzahl an Ferienzeiträumen in aktueller Ansicht ermitteln + Perioden-Indikator setzen + const updateHolidaysInView = React.useCallback(() => { + const inst = scheduleRef.current; + if (!inst) { + setHolidaysInView(0); + return; + } + const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda'; + const baseDate = inst.selectedDate as Date; + if (!baseDate) { + setHolidaysInView(0); + return; + } + let rangeStart = new Date(baseDate); + let rangeEnd = new Date(baseDate); + if (view === 'Day' || view === 'Agenda') { + rangeStart.setHours(0, 0, 0, 0); + rangeEnd.setHours(23, 59, 59, 999); + } else if (view === 'Week' || view === 'WorkWeek') { + const day = baseDate.getDay(); + const diffToMonday = (day + 6) % 7; // Monday=0 + rangeStart = new Date(baseDate); + rangeStart.setDate(baseDate.getDate() - diffToMonday); + rangeStart.setHours(0, 0, 0, 0); + rangeEnd = new Date(rangeStart); + rangeEnd.setDate(rangeStart.getDate() + 6); + rangeEnd.setHours(23, 59, 59, 999); + } else if (view === 'Month') { + rangeStart = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0); + rangeEnd = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999); + } + let count = 0; + for (const h of holidays) { + const hs = new Date(h.start_date + 'T00:00:00'); + const he = new Date(h.end_date + 'T23:59:59'); + const overlaps = + (hs >= rangeStart && hs <= rangeEnd) || + (he >= rangeStart && he <= rangeEnd) || + (hs <= rangeStart && he >= rangeEnd); + if (overlaps) count += 1; + } + setHolidaysInView(count); + // Perioden-Indikator über Backend prüfen + refreshAcademicPeriodFor(baseDate); + }, [holidays, refreshAcademicPeriodFor]); + + // Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln + React.useEffect(() => { + updateHolidaysInView(); + }, [holidays, updateHolidaysInView, schedulerKey]); + return (

Terminmanagement

-
+
+ + + {/* Right-aligned indicators */} +
+ {/* Holidays-in-view badge */} + 0 ? '#ffe8cc' : '#f3f4f6', + border: holidaysInView > 0 ? '1px solid #ffcf99' : '1px solid #e5e7eb', + color: '#000', + padding: '4px 10px', + borderRadius: 16, + fontSize: 12, + }} + > + {holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'} + +
{ groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }} groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined} editMode={editMode} // NEU: Prop für Editiermodus + blockHolidays={!allowScheduleOnHolidays} + isHolidayRange={(s, e) => isWithinHolidayRange(s, e)} /> updateHolidaysInView()} cellClick={args => { + if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) { + args.cancel = true; + return; // block creation on holidays + } // args.startTime und args.endTime sind Date-Objekte args.cancel = true; // Verhindert die Standardaktion setModalInitialData({ @@ -353,6 +659,20 @@ const Appointments: React.FC = () => { } }} eventRendered={(args: EventRenderedArgs) => { + // Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist + if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) { + const s = + args.data.StartTime instanceof Date + ? args.data.StartTime + : new Date(args.data.StartTime); + const e = + args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime); + if (isWithinHolidayRange(s, e)) { + args.cancel = true; + return; + } + } + if (selectedGroupId && args.data && args.data.Id) { const groupColor = getGroupColor(selectedGroupId, groups); const now = new Date(); @@ -389,13 +709,26 @@ const Appointments: React.FC = () => { subjectText; } - // Vergangene Termine: Raumgruppenfarbe mit Transparenz + // Vergangene Termine: Raumgruppenfarbe if (args.data.EndTime && args.data.EndTime < now) { args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3'; - args.element.style.color = ''; + args.element.style.color = '#000'; } else if (groupColor) { args.element.style.backgroundColor = groupColor; - args.element.style.color = ''; + args.element.style.color = '#000'; + } + + // Spezielle Darstellung für Ferienanzeige-Events + if (args.data.isHoliday && !args.data.IsBlock) { + args.element.style.backgroundColor = '#ffe8cc'; // sanftes Orange + args.element.style.border = '1px solid #ffcf99'; + args.element.style.color = '#000'; + } + // Gleiche Darstellung für Ferien-Block-Events + if (args.data.isHoliday && args.data.IsBlock) { + args.element.style.backgroundColor = '#ffe8cc'; + args.element.style.border = '1px solid #ffcf99'; + args.element.style.color = '#000'; } } }} @@ -429,6 +762,25 @@ const Appointments: React.FC = () => { } // Syncfusion soll das Event nicht selbst löschen args.cancel = true; + } else if ( + (args.requestType === 'eventCreate' || args.requestType === 'eventChange') && + !allowScheduleOnHolidays + ) { + // Verhindere Erstellen/Ändern in Ferienbereichen (Failsafe, falls eingebauter Editor genutzt wird) + type PartialEventLike = { StartTime?: Date | string; EndTime?: Date | string }; + const raw = (args as ActionEventArgs).data as + | PartialEventLike + | PartialEventLike[] + | undefined; + const data = Array.isArray(raw) ? raw[0] : raw; + if (data && data.StartTime && data.EndTime) { + const s = data.StartTime instanceof Date ? data.StartTime : new Date(data.StartTime); + const e = data.EndTime instanceof Date ? data.EndTime : new Date(data.EndTime); + if (isWithinHolidayRange(s, e)) { + args.cancel = true; + return; + } + } } }} firstDayOfWeek={1} diff --git a/dashboard/src/components/CustomEventModal.tsx b/dashboard/src/components/CustomEventModal.tsx index aa12c7e..525b397 100644 --- a/dashboard/src/components/CustomEventModal.tsx +++ b/dashboard/src/components/CustomEventModal.tsx @@ -32,6 +32,8 @@ type CustomEventModalProps = { groupName: string | { id: string | null; name: string }; groupColor?: string; editMode?: boolean; + blockHolidays?: boolean; + isHolidayRange?: (start: Date, end: Date) => boolean; }; const weekdayOptions = [ @@ -60,6 +62,8 @@ const CustomEventModal: React.FC = ({ groupName, groupColor, editMode, + blockHolidays, + isHolidayRange, }) => { const [title, setTitle] = React.useState(initialData.title || ''); const [startDate, setStartDate] = React.useState(initialData.startDate || null); @@ -149,6 +153,34 @@ const CustomEventModal: React.FC = ({ if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich'; } + // Holiday blocking: prevent creating when range overlaps + if ( + !editMode && + blockHolidays && + startDate && + startTime && + endTime && + typeof isHolidayRange === 'function' + ) { + const s = new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate(), + startTime.getHours(), + startTime.getMinutes() + ); + const e = new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate(), + endTime.getHours(), + endTime.getMinutes() + ); + if (isHolidayRange(s, e)) { + newErrors.startDate = 'Dieser Zeitraum liegt in den Ferien und ist gesperrt.'; + } + } + if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; diff --git a/dashboard/src/einstellungen.tsx b/dashboard/src/einstellungen.tsx index dc0461d..2f6a325 100644 --- a/dashboard/src/einstellungen.tsx +++ b/dashboard/src/einstellungen.tsx @@ -44,17 +44,21 @@ const Einstellungen: React.FC = () => {

Schulferien importieren

- Laden Sie eine CSV-Datei mit den Spalten: name, start_date,{' '} - end_date, optional region. + Unterstützte Formate: +
• CSV mit Kopfzeile: name, start_date,{' '} + end_date, optional region +
• TXT/CSV ohne Kopfzeile mit Spalten: interner Name, Name,{' '} + Start (YYYYMMDD), Ende (YYYYMMDD), optional interne + Info (ignoriert)

setFile(e.target.files?.[0] ?? null)} />
{message &&
{message}
} diff --git a/server/routes/academic_periods.py b/server/routes/academic_periods.py new file mode 100644 index 0000000..588ff0c --- /dev/null +++ b/server/routes/academic_periods.py @@ -0,0 +1,84 @@ +from flask import Blueprint, jsonify, request +from server.database import Session +from models.models import AcademicPeriod +from datetime import datetime + +academic_periods_bp = Blueprint( + 'academic_periods', __name__, url_prefix='/api/academic_periods') + + +@academic_periods_bp.route('', methods=['GET']) +def list_academic_periods(): + session = Session() + try: + periods = session.query(AcademicPeriod).order_by( + AcademicPeriod.start_date.asc()).all() + return jsonify({ + 'periods': [p.to_dict() for p in periods] + }) + finally: + session.close() + + +@academic_periods_bp.route('/active', methods=['GET']) +def get_active_academic_period(): + session = Session() + try: + period = session.query(AcademicPeriod).filter( + AcademicPeriod.is_active == True).first() + if not period: + return jsonify({'period': None}), 200 + return jsonify({'period': period.to_dict()}), 200 + finally: + session.close() + + +@academic_periods_bp.route('/for_date', methods=['GET']) +def get_period_for_date(): + """ + Returns the academic period that covers the provided date (YYYY-MM-DD). + If multiple match, prefer the one with the latest start_date. + """ + date_str = request.args.get('date') + if not date_str: + return jsonify({'error': 'Missing required query param: date (YYYY-MM-DD)'}), 400 + try: + target = datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError: + return jsonify({'error': 'Invalid date format. Expected YYYY-MM-DD'}), 400 + + session = Session() + try: + period = ( + session.query(AcademicPeriod) + .filter(AcademicPeriod.start_date <= target, AcademicPeriod.end_date >= target) + .order_by(AcademicPeriod.start_date.desc()) + .first() + ) + return jsonify({'period': period.to_dict() if period else None}), 200 + finally: + session.close() + + +@academic_periods_bp.route('/active', methods=['POST']) +def set_active_academic_period(): + data = request.get_json(silent=True) or {} + period_id = data.get('id') + if period_id is None: + return jsonify({'error': 'Missing required field: id'}), 400 + session = Session() + try: + target = session.query(AcademicPeriod).get(period_id) + if not target: + return jsonify({'error': 'AcademicPeriod not found'}), 404 + + # Deactivate all, then activate target + session.query(AcademicPeriod).filter(AcademicPeriod.is_active == True).update( + {AcademicPeriod.is_active: False} + ) + target.is_active = True + session.commit() + session.refresh(target) + return jsonify({'period': target.to_dict()}), 200 + finally: + session.close() diff --git a/server/routes/holidays.py b/server/routes/holidays.py index e0b1b7d..09b2b72 100644 --- a/server/routes/holidays.py +++ b/server/routes/holidays.py @@ -24,9 +24,14 @@ def list_holidays(): @holidays_bp.route("/upload", methods=["POST"]) def upload_holidays(): """ - Accepts a CSV file upload (multipart/form-data) with columns like: - name,start_date,end_date,region - Dates can be in ISO (YYYY-MM-DD) or common European format (DD.MM.YYYY). + Accepts a CSV/TXT file upload (multipart/form-data). + + Supported formats: + 1) Headered CSV with columns (case-insensitive): name, start_date, end_date[, region] + - Dates: YYYY-MM-DD, DD.MM.YYYY, YYYY/MM/DD, or YYYYMMDD + 2) Headerless CSV/TXT lines with columns: + [internal, name, start_yyyymmdd, end_yyyymmdd, optional_internal] + - Only columns 2-4 are used; 1 and 5 are ignored. """ if "file" not in request.files: return jsonify({"error": "No file part"}), 400 @@ -35,26 +40,36 @@ def upload_holidays(): return jsonify({"error": "No selected file"}), 400 try: - content = file.read().decode("utf-8", errors="ignore") - # Try to auto-detect delimiter; default ',' + raw = file.read() + # Try UTF-8 first (strict), then cp1252, then latin-1 as last resort + try: + content = raw.decode("utf-8") + except UnicodeDecodeError: + try: + content = raw.decode("cp1252") + except UnicodeDecodeError: + content = raw.decode("latin-1", errors="replace") + sniffer = csv.Sniffer() dialect = None try: - dialect = sniffer.sniff(content[:1024]) + sample = content[:2048] + # Some files may contain a lot of quotes; allow Sniffer to guess delimiter + dialect = sniffer.sniff(sample) except Exception: pass - reader = csv.DictReader(io.StringIO( - content), dialect=dialect) if dialect else csv.DictReader(io.StringIO(content)) - - required = {"name", "start_date", "end_date"} - if not required.issubset(set(h.lower() for h in reader.fieldnames or [])): - return jsonify({"error": "CSV must contain headers: name, start_date, end_date"}), 400 def parse_date(s: str): s = (s or "").strip() if not s: return None - # Try ISO first + # Numeric YYYYMMDD + if s.isdigit() and len(s) == 8: + try: + return datetime.strptime(s, "%Y%m%d").date() + except ValueError: + pass + # Common formats for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y/%m/%d"): try: return datetime.strptime(s, fmt).date() @@ -65,16 +80,18 @@ def upload_holidays(): session = Session() inserted = 0 updated = 0 - for row in reader: - # Normalize headers to lower-case keys - norm = {k.lower(): (v or "").strip() for k, v in row.items()} - name = norm.get("name") - start_date = parse_date(norm.get("start_date")) - end_date = parse_date(norm.get("end_date")) - region = norm.get("region") or None - if not name or not start_date or not end_date: - continue + # First, try headered CSV via DictReader + dict_reader = csv.DictReader(io.StringIO( + content), dialect=dialect) if dialect else csv.DictReader(io.StringIO(content)) + fieldnames_lower = [h.lower() for h in (dict_reader.fieldnames or [])] + has_required_headers = {"name", "start_date", + "end_date"}.issubset(set(fieldnames_lower)) + + def upsert(name: str, start_date, end_date, region=None): + nonlocal inserted, updated + if not name or not start_date or not end_date: + return existing = ( session.query(SchoolHoliday) .filter( @@ -86,9 +103,7 @@ def upload_holidays(): ) .first() ) - if existing: - # Optionally update region or source_file_name existing.region = region existing.source_file_name = file.filename updated += 1 @@ -102,6 +117,41 @@ def upload_holidays(): )) inserted += 1 + if has_required_headers: + for row in dict_reader: + norm = {k.lower(): (v or "").strip() for k, v in row.items()} + name = norm.get("name") + try: + start_date = parse_date(norm.get("start_date")) + end_date = parse_date(norm.get("end_date")) + except ValueError: + # Skip rows with unparseable dates + continue + region = (norm.get("region") + or None) if "region" in norm else None + upsert(name, start_date, end_date, region) + else: + # Fallback: headerless rows -> use columns [1]=name, [2]=start, [3]=end + reader = csv.reader(io.StringIO( + content), dialect=dialect) if dialect else csv.reader(io.StringIO(content)) + for row in reader: + if not row: + continue + # tolerate varying column counts (4 or 5); ignore first and optional last + cols = [c.strip() for c in row] + if len(cols) < 4: + # Not enough data + continue + name = cols[1].strip().strip('"') + start_raw = cols[2] + end_raw = cols[3] + try: + start_date = parse_date(start_raw) + end_date = parse_date(end_raw) + except ValueError: + continue + upsert(name, start_date, end_date, None) + session.commit() session.close() return jsonify({"success": True, "inserted": inserted, "updated": updated}) diff --git a/server/wsgi.py b/server/wsgi.py index ff53ca6..0b6fea6 100644 --- a/server/wsgi.py +++ b/server/wsgi.py @@ -3,6 +3,7 @@ from server.routes.eventmedia import eventmedia_bp from server.routes.files import files_bp from server.routes.events import events_bp from server.routes.holidays import holidays_bp +from server.routes.academic_periods import academic_periods_bp from server.routes.groups import groups_bp from server.routes.clients import clients_bp from server.database import Session, engine @@ -22,6 +23,7 @@ app.register_blueprint(events_bp) app.register_blueprint(eventmedia_bp) app.register_blueprint(files_bp) app.register_blueprint(holidays_bp) +app.register_blueprint(academic_periods_bp) @app.route("/health")