feat(events): reliable holiday skipping for recurrences + UI badge; clean logs
Backend: generate EventException on create/update when skip_holidays or recurrence changes; emit RecurrenceException (EXDATE) with exact occurrence start time (UTC) API: return master events with RecurrenceRule + RecurrenceException Frontend: map RecurrenceException → recurrenceException; ensure SkipHolidays instances never render on holidays; place TentTree icon (black) next to main event icon via template Docs: update README and Copilot instructions for recurrence/holiday behavior Cleanup: remove dataSource and debug console logs
This commit is contained in:
10
.github/copilot-instructions.md
vendored
10
.github/copilot-instructions.md
vendored
@@ -69,6 +69,11 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
|
|||||||
- Holidays present in the current view (count)
|
- 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)
|
- Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check)
|
||||||
|
|
||||||
|
- Recurrence & holidays (latest):
|
||||||
|
- Backend stores holiday skips in `EventException` and emits `RecurrenceException` (EXDATE) for master events in `GET /api/events`. EXDATE timestamps match each occurrence start time (UTC) so Syncfusion excludes instances on holidays reliably.
|
||||||
|
- Frontend maps `RecurrenceException` to `recurrenceException` and relies on Syncfusion to expand recurrences. Additionally, `eventRendered` cancels rendering for any instance with `SkipHolidays=true` falling within holiday ranges, independent of the “Termine an Ferientagen erlauben” toggle.
|
||||||
|
- UI: Events with `SkipHolidays` render a TentTree icon directly after the main event icon in the scheduler event template. Icon color: black.
|
||||||
|
|
||||||
- Program info page (`dashboard/src/programminfo.tsx`):
|
- Program info page (`dashboard/src/programminfo.tsx`):
|
||||||
- Loads data from `dashboard/public/program-info.json` (app name, version, build info, tech stack, changelog).
|
- Loads data from `dashboard/public/program-info.json` (app name, version, build info, tech stack, changelog).
|
||||||
- Uses Syncfusion card classes (`e-card`, `e-card-header`, `e-card-title`, `e-card-content`) for consistent styling.
|
- Uses Syncfusion card classes (`e-card`, `e-card-header`, `e-card-title`, `e-card-content`) for consistent styling.
|
||||||
@@ -117,6 +122,11 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for
|
|||||||
- 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`).
|
- 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`).
|
||||||
- Initialization scripts: legacy DB init scripts were removed; use Alembic and `initialize_database.py` going forward.
|
- Initialization scripts: legacy DB init scripts were removed; use Alembic and `initialize_database.py` going forward.
|
||||||
|
|
||||||
|
### Recurrence & holidays: conventions
|
||||||
|
- Do not pre-expand recurrences on the backend. Always send master event with `RecurrenceRule` + `RecurrenceException`.
|
||||||
|
- Ensure EXDATE tokens include the occurrence start time (HH:mm:ss) in UTC to match Syncfusion’s expansion.
|
||||||
|
- When `skip_holidays` or recurrence changes, regenerate `EventException` rows so `RecurrenceException` stays in sync.
|
||||||
|
|
||||||
## Quick examples
|
## Quick examples
|
||||||
- Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients/<uuid>/description` in `routes/clients.py`.
|
- Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients/<uuid>/description` in `routes/clients.py`.
|
||||||
- Bulk group assignment emits retained messages for each client: `PUT /api/clients/group`.
|
- Bulk group assignment emits retained messages for each client: `PUT /api/clients/group`.
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ A comprehensive multi-service digital signage solution for educational instituti
|
|||||||
- Event scheduling with academic period support
|
- Event scheduling with academic period support
|
||||||
- Media management with presentation conversion
|
- Media management with presentation conversion
|
||||||
- Holiday calendar integration
|
- Holiday calendar integration
|
||||||
|
- Visual indicators: TentTree icon next to the main event icon marks events that skip holidays (icon color: black)
|
||||||
|
|
||||||
### 🎯 **Event System**
|
### 🎯 **Event System**
|
||||||
- **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg
|
- **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg
|
||||||
@@ -52,6 +53,7 @@ A comprehensive multi-service digital signage solution for educational instituti
|
|||||||
- **Videos**: Media file streaming
|
- **Videos**: Media file streaming
|
||||||
- **Messages**: Text announcements
|
- **Messages**: Text announcements
|
||||||
- **WebUntis**: Educational schedule integration
|
- **WebUntis**: Educational schedule integration
|
||||||
|
- **Recurrence & Holidays**: Recurring events can be configured to skip holidays. The backend generates EXDATEs (RecurrenceException) for holiday occurrences so the calendar never shows those instances. The "Termine an Ferientagen erlauben" toggle does not affect these events.
|
||||||
|
|
||||||
### 🏫 **Academic Period Management**
|
### 🏫 **Academic Period Management**
|
||||||
- Support for school years, semesters, and trimesters
|
- Support for school years, semesters, and trimesters
|
||||||
@@ -141,6 +143,7 @@ For detailed deployment instructions, see:
|
|||||||
- **Technology**: Flask + SQLAlchemy + Alembic
|
- **Technology**: Flask + SQLAlchemy + Alembic
|
||||||
- **Database**: MariaDB with timezone-aware timestamps
|
- **Database**: MariaDB with timezone-aware timestamps
|
||||||
- **Features**: RESTful API, file uploads, MQTT integration
|
- **Features**: RESTful API, file uploads, MQTT integration
|
||||||
|
- Recurrence/holidays: returns only master events with `RecurrenceRule` and `RecurrenceException` (EXDATEs) so clients render recurrences and skip holiday instances reliably.
|
||||||
- **Port**: 8000
|
- **Port**: 8000
|
||||||
- **Health Check**: `/health`
|
- **Health Check**: `/health`
|
||||||
|
|
||||||
@@ -271,6 +274,11 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
|
|||||||
|
|
||||||
## 🎨 Frontend Features
|
## 🎨 Frontend Features
|
||||||
|
|
||||||
|
### Recurrence & holidays
|
||||||
|
- The Scheduler (Syncfusion) expands master events via `RecurrenceRule`.
|
||||||
|
- The API supplies `recurrenceException` (EXDATE) with exact occurrence start times (UTC) so holiday instances are excluded.
|
||||||
|
- Events with “skip holidays” display a TentTree icon next to the main event icon.
|
||||||
|
|
||||||
### Syncfusion Components Used (Material 3)
|
### Syncfusion Components Used (Material 3)
|
||||||
- **Schedule**: Event calendar with drag-drop support
|
- **Schedule**: Event calendar with drag-drop support
|
||||||
- **Grid**: Data tables with filtering and sorting
|
- **Grid**: Data tables with filtering and sorting
|
||||||
|
|||||||
31
dashboard/src/apiEventExceptions.ts
Normal file
31
dashboard/src/apiEventExceptions.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export interface EventException {
|
||||||
|
id?: number;
|
||||||
|
event_id: number;
|
||||||
|
exception_date: string; // YYYY-MM-DD
|
||||||
|
is_skipped: boolean;
|
||||||
|
override_title?: string;
|
||||||
|
override_description?: string;
|
||||||
|
override_start?: string;
|
||||||
|
override_end?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEventException(exception: Omit<EventException, 'id'>) {
|
||||||
|
const res = await fetch('/api/event_exceptions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(exception),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Erstellen der Ausnahme');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEventExceptions(eventId?: number) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (eventId) params.set('event_id', eventId.toString());
|
||||||
|
|
||||||
|
const res = await fetch(`/api/event_exceptions?${params.toString()}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ausnahmen');
|
||||||
|
return data as EventException[];
|
||||||
|
}
|
||||||
@@ -8,10 +8,18 @@ export interface Event {
|
|||||||
extendedProps: Record<string, unknown>;
|
extendedProps: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEvents(groupId: string, showInactive = false) {
|
export async function fetchEvents(
|
||||||
const res = await fetch(
|
groupId: string,
|
||||||
`/api/events?group_id=${encodeURIComponent(groupId)}&show_inactive=${showInactive ? '1' : '0'}`
|
showInactive = false,
|
||||||
);
|
options?: { start?: Date; end?: Date; expand?: boolean }
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('group_id', groupId);
|
||||||
|
params.set('show_inactive', showInactive ? '1' : '0');
|
||||||
|
if (options?.start) params.set('start', options.start.toISOString());
|
||||||
|
if (options?.end) params.set('end', options.end.toISOString());
|
||||||
|
if (options?.expand) params.set('expand', options.expand ? '1' : '0');
|
||||||
|
const res = await fetch(`/api/events?${params.toString()}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Termine');
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Termine');
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ import {
|
|||||||
School,
|
School,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
TentTree,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
import caGregorian from './cldr/ca-gregorian.json';
|
import caGregorian from './cldr/ca-gregorian.json';
|
||||||
import numbers from './cldr/numbers.json';
|
import numbers from './cldr/numbers.json';
|
||||||
import timeZoneNames from './cldr/timeZoneNames.json';
|
import timeZoneNames from './cldr/timeZoneNames.json';
|
||||||
@@ -64,6 +64,12 @@ type Event = {
|
|||||||
WebsiteUrl?: string;
|
WebsiteUrl?: string;
|
||||||
Icon?: string; // <--- Icon ergänzen!
|
Icon?: string; // <--- Icon ergänzen!
|
||||||
Type?: string; // <--- Typ ergänzen, falls benötigt
|
Type?: string; // <--- Typ ergänzen, falls benötigt
|
||||||
|
OccurrenceOfId?: string; // Serieninstanz
|
||||||
|
Recurrence?: boolean; // Marker für Serientermin
|
||||||
|
RecurrenceRule?: string | null;
|
||||||
|
RecurrenceEnd?: string | null;
|
||||||
|
SkipHolidays?: boolean;
|
||||||
|
RecurrenceException?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RawEvent = {
|
type RawEvent = {
|
||||||
@@ -75,6 +81,11 @@ type RawEvent = {
|
|||||||
MediaId?: string | number;
|
MediaId?: string | number;
|
||||||
Icon?: string; // <--- Icon ergänzen!
|
Icon?: string; // <--- Icon ergänzen!
|
||||||
Type?: string;
|
Type?: string;
|
||||||
|
OccurrenceOfId?: string;
|
||||||
|
RecurrenceRule?: string | null;
|
||||||
|
RecurrenceEnd?: string | null;
|
||||||
|
SkipHolidays?: boolean;
|
||||||
|
RecurrenceException?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// CLDR-Daten laden (direkt die JSON-Objekte übergeben)
|
// CLDR-Daten laden (direkt die JSON-Objekte übergeben)
|
||||||
@@ -137,6 +148,8 @@ const eventTemplate = (event: Event) => {
|
|||||||
const end = event.EndTime instanceof Date ? event.EndTime : new Date(event.EndTime);
|
const end = event.EndTime instanceof Date ? event.EndTime : new Date(event.EndTime);
|
||||||
const timeString = `${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
const timeString = `${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
|
||||||
|
// Render TentTree icon if SkipHolidays is true
|
||||||
|
const showTentTree = !!event.SkipHolidays;
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', color: '#000', marginBottom: 2 }}>
|
<div style={{ display: 'flex', alignItems: 'center', color: '#000', marginBottom: 2 }}>
|
||||||
@@ -145,17 +158,36 @@ const eventTemplate = (event: Event) => {
|
|||||||
<IconComponent size={18} color="#000" />
|
<IconComponent size={18} color="#000" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showTentTree && (
|
||||||
|
<span
|
||||||
|
style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}
|
||||||
|
title="Ferientage werden berücksichtigt"
|
||||||
|
>
|
||||||
|
<TentTree size={16} color="#000" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span style={{ marginTop: 3 }}>{event.Subject}</span>
|
<span style={{ marginTop: 3 }}>{event.Subject}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.95em', color: '#000', marginTop: -2 }}>{timeString}</div>
|
<div style={{ fontSize: '0.95em', color: '#000', marginTop: -2 }}>{timeString}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// Robust ISO date parsing: append 'Z' only if no timezone offset present
|
||||||
|
function parseEventDate(iso: string): Date {
|
||||||
|
if (!iso) return new Date(NaN);
|
||||||
|
// If string already contains 'Z' or a timezone offset like +00:00/-02:00, don't modify
|
||||||
|
const hasTZ = /Z$/i.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso);
|
||||||
|
const s = hasTZ ? iso : `${iso}Z`;
|
||||||
|
return new Date(s);
|
||||||
|
}
|
||||||
|
|
||||||
const Appointments: React.FC = () => {
|
const Appointments: React.FC = () => {
|
||||||
const [groups, setGroups] = useState<Group[]>([]);
|
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
// Debug logging removed
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [modalInitialData, setModalInitialData] = useState({});
|
const [modalInitialData, setModalInitialData] = useState({});
|
||||||
@@ -164,6 +196,8 @@ const Appointments: React.FC = () => {
|
|||||||
const [showInactive, setShowInactive] = React.useState(true);
|
const [showInactive, setShowInactive] = React.useState(true);
|
||||||
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
|
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
|
||||||
const [showHolidayList, setShowHolidayList] = React.useState(true);
|
const [showHolidayList, setShowHolidayList] = React.useState(true);
|
||||||
|
// Always let Syncfusion handle recurrence; do not expand on backend
|
||||||
|
// Remove expandRecurrences toggle and state
|
||||||
const scheduleRef = React.useRef<ScheduleComponent | null>(null);
|
const scheduleRef = React.useRef<ScheduleComponent | null>(null);
|
||||||
const [holidaysInView, setHolidaysInView] = React.useState<number>(0);
|
const [holidaysInView, setHolidaysInView] = React.useState<number>(0);
|
||||||
const [schoolYearLabel, setSchoolYearLabel] = React.useState<string>('');
|
const [schoolYearLabel, setSchoolYearLabel] = React.useState<string>('');
|
||||||
@@ -208,16 +242,70 @@ const Appointments: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await fetchEvents(selectedGroupId, showInactive); // selectedGroupId ist jetzt garantiert string
|
// Sichtbaren Bereich bestimmen (optional, for future filtering)
|
||||||
|
const inst = scheduleRef.current;
|
||||||
|
let startRange: Date | undefined;
|
||||||
|
let endRange: Date | undefined;
|
||||||
|
// Fallback to current week if scheduler is not initialized yet
|
||||||
|
const today = new Date();
|
||||||
|
const fallbackStart = new Date(today);
|
||||||
|
const day = today.getDay();
|
||||||
|
const diffToMonday = (day + 6) % 7; // Monday=0
|
||||||
|
fallbackStart.setDate(today.getDate() - diffToMonday);
|
||||||
|
fallbackStart.setHours(0, 0, 0, 0);
|
||||||
|
const fallbackEnd = new Date(fallbackStart);
|
||||||
|
fallbackEnd.setDate(fallbackStart.getDate() + 6);
|
||||||
|
fallbackEnd.setHours(23, 59, 59, 999);
|
||||||
|
if (inst) {
|
||||||
|
const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda';
|
||||||
|
const baseDate = inst.selectedDate as Date;
|
||||||
|
if (baseDate) {
|
||||||
|
if (view === 'Day' || view === 'Agenda') {
|
||||||
|
startRange = new Date(baseDate);
|
||||||
|
startRange.setHours(0, 0, 0, 0);
|
||||||
|
endRange = new Date(baseDate);
|
||||||
|
endRange.setHours(23, 59, 59, 999);
|
||||||
|
} else if (view === 'Week' || view === 'WorkWeek') {
|
||||||
|
const day = baseDate.getDay();
|
||||||
|
const diffToMonday = (day + 6) % 7; // Monday=0
|
||||||
|
startRange = new Date(baseDate);
|
||||||
|
startRange.setDate(baseDate.getDate() - diffToMonday);
|
||||||
|
startRange.setHours(0, 0, 0, 0);
|
||||||
|
endRange = new Date(startRange);
|
||||||
|
endRange.setDate(startRange.getDate() + 6);
|
||||||
|
endRange.setHours(23, 59, 59, 999);
|
||||||
|
} else if (view === 'Month') {
|
||||||
|
startRange = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0);
|
||||||
|
endRange = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!startRange || !endRange) {
|
||||||
|
startRange = fallbackStart;
|
||||||
|
endRange = fallbackEnd;
|
||||||
|
}
|
||||||
|
// Always request non-expanded events (expand=0)
|
||||||
|
const data = await fetchEvents(selectedGroupId, showInactive, {
|
||||||
|
start: startRange!,
|
||||||
|
end: endRange!,
|
||||||
|
expand: false,
|
||||||
|
});
|
||||||
const mapped: Event[] = data.map((e: RawEvent) => ({
|
const mapped: Event[] = data.map((e: RawEvent) => ({
|
||||||
Id: e.Id,
|
Id: e.Id,
|
||||||
Subject: e.Subject,
|
Subject: e.Subject,
|
||||||
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
|
StartTime: parseEventDate(e.StartTime),
|
||||||
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
|
EndTime: parseEventDate(e.EndTime),
|
||||||
IsAllDay: e.IsAllDay,
|
IsAllDay: e.IsAllDay,
|
||||||
MediaId: e.MediaId,
|
MediaId: e.MediaId,
|
||||||
Icon: e.Icon, // <--- Icon übernehmen!
|
Icon: e.Icon,
|
||||||
Type: e.Type, // <--- Typ übernehmen!
|
Type: e.Type,
|
||||||
|
OccurrenceOfId: e.OccurrenceOfId,
|
||||||
|
Recurrence: !!e.RecurrenceRule,
|
||||||
|
RecurrenceRule: e.RecurrenceRule ?? null,
|
||||||
|
RecurrenceEnd: e.RecurrenceEnd ?? null,
|
||||||
|
SkipHolidays: e.SkipHolidays ?? false,
|
||||||
|
RecurrenceException: e.RecurrenceException ?? undefined,
|
||||||
|
recurrenceException: e.RecurrenceException ?? undefined, // for Syncfusion
|
||||||
}));
|
}));
|
||||||
setEvents(mapped);
|
setEvents(mapped);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -296,8 +384,30 @@ const Appointments: React.FC = () => {
|
|||||||
}, [holidays, allowScheduleOnHolidays]);
|
}, [holidays, allowScheduleOnHolidays]);
|
||||||
|
|
||||||
const dataSource = useMemo(() => {
|
const dataSource = useMemo(() => {
|
||||||
return [...events, ...holidayDisplayEvents, ...holidayBlockEvents];
|
// Filter: Events with SkipHolidays=true are never shown on holidays, regardless of toggle
|
||||||
}, [events, holidayDisplayEvents, holidayBlockEvents]);
|
const filteredEvents = events.filter(ev => {
|
||||||
|
if (ev.SkipHolidays) {
|
||||||
|
// If event falls within a holiday, hide it
|
||||||
|
const s = ev.StartTime instanceof Date ? ev.StartTime : new Date(ev.StartTime);
|
||||||
|
const e = ev.EndTime instanceof Date ? ev.EndTime : new Date(ev.EndTime);
|
||||||
|
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 (
|
||||||
|
(s >= hs && s <= he) ||
|
||||||
|
(e >= hs && e <= he) ||
|
||||||
|
(s <= hs && e >= he)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return [...filteredEvents, ...holidayDisplayEvents, ...holidayBlockEvents];
|
||||||
|
}, [events, holidayDisplayEvents, holidayBlockEvents, holidays]);
|
||||||
|
|
||||||
|
// Removed dataSource logging
|
||||||
|
|
||||||
// Aktive akademische Periode für Datum aus dem Backend ermitteln
|
// Aktive akademische Periode für Datum aus dem Backend ermitteln
|
||||||
const refreshAcademicPeriodFor = React.useCallback(
|
const refreshAcademicPeriodFor = React.useCallback(
|
||||||
@@ -529,6 +639,7 @@ const Appointments: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
Ferien im Kalender anzeigen
|
Ferien im Kalender anzeigen
|
||||||
</label>
|
</label>
|
||||||
|
{/* Removed expandRecurrences toggle: Syncfusion now always handles recurrence */}
|
||||||
{/* Right-aligned indicators */}
|
{/* Right-aligned indicators */}
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
{/* Holidays-in-view badge */}
|
{/* Holidays-in-view badge */}
|
||||||
@@ -546,6 +657,7 @@ const Appointments: React.FC = () => {
|
|||||||
{holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'}
|
{holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Removed expandRecurrences indicator: Syncfusion now always handles recurrence */}
|
||||||
</div>
|
</div>
|
||||||
<CustomEventModal
|
<CustomEventModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
@@ -556,19 +668,9 @@ const Appointments: React.FC = () => {
|
|||||||
onSave={async () => {
|
onSave={async () => {
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
if (selectedGroupId) {
|
// Reload events using the same logic as fetchAndSetEvents
|
||||||
const data = await fetchEvents(selectedGroupId, showInactive);
|
await fetchAndSetEvents();
|
||||||
const mapped: Event[] = data.map((e: RawEvent) => ({
|
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
|
||||||
Id: e.Id,
|
|
||||||
Subject: e.Subject,
|
|
||||||
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
|
|
||||||
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
|
|
||||||
IsAllDay: e.IsAllDay,
|
|
||||||
MediaId: e.MediaId,
|
|
||||||
}));
|
|
||||||
setEvents(mapped);
|
|
||||||
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
initialData={modalInitialData}
|
initialData={modalInitialData}
|
||||||
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
||||||
@@ -585,10 +687,22 @@ const Appointments: React.FC = () => {
|
|||||||
currentView="Week"
|
currentView="Week"
|
||||||
eventSettings={{
|
eventSettings={{
|
||||||
dataSource: dataSource,
|
dataSource: dataSource,
|
||||||
fields: { isBlock: 'IsBlock' },
|
fields: {
|
||||||
|
id: 'Id',
|
||||||
|
subject: 'Subject',
|
||||||
|
startTime: 'StartTime',
|
||||||
|
endTime: 'EndTime',
|
||||||
|
isBlock: 'IsBlock',
|
||||||
|
},
|
||||||
template: eventTemplate, // <--- Hier das Template setzen!
|
template: eventTemplate, // <--- Hier das Template setzen!
|
||||||
}}
|
}}
|
||||||
actionComplete={() => updateHolidaysInView()}
|
actionComplete={(args) => {
|
||||||
|
updateHolidaysInView();
|
||||||
|
// Bei Navigation oder Viewwechsel Events erneut laden (für Range-basierte Expansion)
|
||||||
|
if (args && (args.requestType === 'dateNavigate' || args.requestType === 'viewNavigate')) {
|
||||||
|
fetchAndSetEvents();
|
||||||
|
}
|
||||||
|
}}
|
||||||
cellClick={args => {
|
cellClick={args => {
|
||||||
if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) {
|
if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) {
|
||||||
args.cancel = true;
|
args.cancel = true;
|
||||||
@@ -608,7 +722,7 @@ const Appointments: React.FC = () => {
|
|||||||
if (args.type === 'Editor') {
|
if (args.type === 'Editor') {
|
||||||
args.cancel = true;
|
args.cancel = true;
|
||||||
const event = args.data;
|
const event = args.data;
|
||||||
console.log('Event zum Bearbeiten:', event);
|
// Removed event logging
|
||||||
let media = null;
|
let media = null;
|
||||||
if (event.MediaId) {
|
if (event.MediaId) {
|
||||||
try {
|
try {
|
||||||
@@ -622,6 +736,29 @@ const Appointments: React.FC = () => {
|
|||||||
console.error('Fehler beim Laden der Mediainfos:', err);
|
console.error('Fehler beim Laden der Mediainfos:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Parse recurrence info if present (supports FREQ=WEEKLY;BYDAY=...;[UNTIL=...])
|
||||||
|
let repeat = false;
|
||||||
|
let weekdays: number[] = [];
|
||||||
|
let repeatUntil: Date | null = null;
|
||||||
|
const rr = (event.RecurrenceRule as string) || '';
|
||||||
|
if (rr && rr.includes('FREQ=WEEKLY')) {
|
||||||
|
repeat = true;
|
||||||
|
const m = rr.match(/BYDAY=([^;]+)/);
|
||||||
|
if (m && m[1]) {
|
||||||
|
const map: Record<string, number> = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 };
|
||||||
|
weekdays = m[1].split(',').map(s => map[s]).filter(v => v !== undefined);
|
||||||
|
}
|
||||||
|
const mu = rr.match(/UNTIL=([0-9TZ]+)/);
|
||||||
|
if (mu && mu[1]) {
|
||||||
|
// UNTIL is UTC in yyyymmddThhmmssZ form; we take date part
|
||||||
|
const untilIso = mu[1]
|
||||||
|
.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z');
|
||||||
|
repeatUntil = new Date(untilIso);
|
||||||
|
} else if (event.RecurrenceEnd) {
|
||||||
|
repeatUntil = new Date(event.RecurrenceEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setModalInitialData({
|
setModalInitialData({
|
||||||
Id: event.Id,
|
Id: event.Id,
|
||||||
title: event.Subject,
|
title: event.Subject,
|
||||||
@@ -630,36 +767,39 @@ const Appointments: React.FC = () => {
|
|||||||
endTime: event.EndTime,
|
endTime: event.EndTime,
|
||||||
description: event.Description ?? '',
|
description: event.Description ?? '',
|
||||||
type: event.Type ?? 'presentation',
|
type: event.Type ?? 'presentation',
|
||||||
repeat: event.Repeat ?? false,
|
repeat,
|
||||||
weekdays: event.Weekdays ?? [],
|
weekdays,
|
||||||
repeatUntil: event.RepeatUntil ?? null,
|
repeatUntil,
|
||||||
skipHolidays: event.SkipHolidays ?? false,
|
|
||||||
media, // Metadaten werden nur bei Bedarf geladen!
|
|
||||||
slideshowInterval: event.SlideshowInterval ?? 10,
|
|
||||||
websiteUrl: event.WebsiteUrl ?? '',
|
|
||||||
});
|
|
||||||
console.log('Modal initial data:', {
|
|
||||||
Id: event.Id,
|
|
||||||
title: event.Subject,
|
|
||||||
startDate: event.StartTime,
|
|
||||||
startTime: event.StartTime,
|
|
||||||
endTime: event.EndTime,
|
|
||||||
description: event.Description ?? '',
|
|
||||||
type: event.Type ?? 'presentation',
|
|
||||||
repeat: event.Repeat ?? false,
|
|
||||||
weekdays: event.Weekdays ?? [],
|
|
||||||
repeatUntil: event.RepeatUntil ?? null,
|
|
||||||
skipHolidays: event.SkipHolidays ?? false,
|
skipHolidays: event.SkipHolidays ?? false,
|
||||||
media, // Metadaten werden nur bei Bedarf geladen!
|
media, // Metadaten werden nur bei Bedarf geladen!
|
||||||
slideshowInterval: event.SlideshowInterval ?? 10,
|
slideshowInterval: event.SlideshowInterval ?? 10,
|
||||||
websiteUrl: event.WebsiteUrl ?? '',
|
websiteUrl: event.WebsiteUrl ?? '',
|
||||||
});
|
});
|
||||||
|
// Removed modal initial data logging
|
||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
eventRendered={(args: EventRenderedArgs) => {
|
eventRendered={(args: EventRenderedArgs) => {
|
||||||
|
// Always hide events that skip holidays when they fall on holidays, regardless of toggle
|
||||||
|
if (args.data) {
|
||||||
|
const ev = args.data as unknown as Partial<Event>;
|
||||||
|
if (ev.SkipHolidays && !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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist
|
// Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist
|
||||||
|
// Hide events on holidays if not allowed
|
||||||
if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) {
|
if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) {
|
||||||
const s =
|
const s =
|
||||||
args.data.StartTime instanceof Date
|
args.data.StartTime instanceof Date
|
||||||
@@ -677,38 +817,6 @@ const Appointments: React.FC = () => {
|
|||||||
const groupColor = getGroupColor(selectedGroupId, groups);
|
const groupColor = getGroupColor(selectedGroupId, groups);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
let IconComponent: React.ElementType | null = null;
|
|
||||||
switch (args.data.Type) {
|
|
||||||
case 'presentation':
|
|
||||||
IconComponent = Presentation;
|
|
||||||
break;
|
|
||||||
case 'website':
|
|
||||||
IconComponent = Globe;
|
|
||||||
break;
|
|
||||||
case 'video':
|
|
||||||
IconComponent = Video;
|
|
||||||
break;
|
|
||||||
case 'message':
|
|
||||||
IconComponent = MessageSquare;
|
|
||||||
break;
|
|
||||||
case 'webuntis':
|
|
||||||
IconComponent = School;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
IconComponent = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nur .e-subject verwenden!
|
|
||||||
const titleElement = args.element.querySelector('.e-subject');
|
|
||||||
if (titleElement && IconComponent) {
|
|
||||||
const svgString = renderToStaticMarkup(<IconComponent size={18} color="#78591c" />);
|
|
||||||
// Immer nur den reinen Text nehmen, kein vorhandenes Icon!
|
|
||||||
const subjectText = (titleElement as HTMLElement).textContent ?? '';
|
|
||||||
(titleElement as HTMLElement).innerHTML =
|
|
||||||
`<span style="vertical-align:middle;display:inline-block;margin-right:6px;">${svgString}</span>` +
|
|
||||||
subjectText;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vergangene Termine: Raumgruppenfarbe
|
// Vergangene Termine: Raumgruppenfarbe
|
||||||
if (args.data.EndTime && args.data.EndTime < now) {
|
if (args.data.EndTime && args.data.EndTime < now) {
|
||||||
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
|
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
|
||||||
@@ -751,10 +859,11 @@ const Appointments: React.FC = () => {
|
|||||||
const mapped: Event[] = data.map((e: RawEvent) => ({
|
const mapped: Event[] = data.map((e: RawEvent) => ({
|
||||||
Id: e.Id,
|
Id: e.Id,
|
||||||
Subject: e.Subject,
|
Subject: e.Subject,
|
||||||
StartTime: new Date(e.StartTime),
|
StartTime: parseEventDate(e.StartTime),
|
||||||
EndTime: new Date(e.EndTime),
|
EndTime: parseEventDate(e.EndTime),
|
||||||
IsAllDay: e.IsAllDay,
|
IsAllDay: e.IsAllDay,
|
||||||
MediaId: e.MediaId,
|
MediaId: e.MediaId,
|
||||||
|
SkipHolidays: e.SkipHolidays ?? false,
|
||||||
}));
|
}));
|
||||||
setEvents(mapped);
|
setEvents(mapped);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-rea
|
|||||||
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
||||||
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
|
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
|
||||||
import { updateEvent } from '../apiEvents';
|
import { updateEvent } from '../apiEvents';
|
||||||
|
// Holiday exceptions are now created in the backend
|
||||||
|
|
||||||
type CustomEventData = {
|
type CustomEventData = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -76,7 +77,10 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
const [repeat, setRepeat] = React.useState(initialData.repeat || false);
|
const [repeat, setRepeat] = React.useState(initialData.repeat || false);
|
||||||
const [weekdays, setWeekdays] = React.useState<number[]>(initialData.weekdays || []);
|
const [weekdays, setWeekdays] = React.useState<number[]>(initialData.weekdays || []);
|
||||||
const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null);
|
const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null);
|
||||||
const [skipHolidays, setSkipHolidays] = React.useState(initialData.skipHolidays || false);
|
// Default to true so recurrences skip holidays by default
|
||||||
|
const [skipHolidays, setSkipHolidays] = React.useState(
|
||||||
|
initialData.skipHolidays !== undefined ? initialData.skipHolidays : true
|
||||||
|
);
|
||||||
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
|
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
|
||||||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||||
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
|
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
|
||||||
@@ -104,7 +108,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
setRepeat(initialData.repeat || false);
|
setRepeat(initialData.repeat || false);
|
||||||
setWeekdays(initialData.weekdays || []);
|
setWeekdays(initialData.weekdays || []);
|
||||||
setRepeatUntil(initialData.repeatUntil || null);
|
setRepeatUntil(initialData.repeatUntil || null);
|
||||||
setSkipHolidays(initialData.skipHolidays || false);
|
setSkipHolidays(initialData.skipHolidays !== undefined ? initialData.skipHolidays : true);
|
||||||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||||
setMedia(initialData.media ?? null);
|
setMedia(initialData.media ?? null);
|
||||||
setSlideshowInterval(initialData.slideshowInterval ?? 10);
|
setSlideshowInterval(initialData.slideshowInterval ?? 10);
|
||||||
@@ -190,6 +194,27 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
|
|
||||||
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
||||||
|
|
||||||
|
// Build recurrence rule if repeat is enabled
|
||||||
|
let recurrenceRule = null;
|
||||||
|
let recurrenceEnd = null;
|
||||||
|
if (repeat && weekdays.length > 0) {
|
||||||
|
// Convert weekdays to RRULE format (0=Monday -> MO)
|
||||||
|
const rruleDays = weekdays.map(day => {
|
||||||
|
const dayNames = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
|
||||||
|
return dayNames[day];
|
||||||
|
}).join(',');
|
||||||
|
|
||||||
|
recurrenceRule = `FREQ=WEEKLY;BYDAY=${rruleDays}`;
|
||||||
|
if (repeatUntil) {
|
||||||
|
const untilDate = new Date(repeatUntil);
|
||||||
|
untilDate.setHours(23, 59, 59);
|
||||||
|
recurrenceEnd = untilDate.toISOString();
|
||||||
|
// Note: RRULE UNTIL should be in UTC format for consistency
|
||||||
|
const untilUTC = untilDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||||
|
recurrenceRule += `;UNTIL=${untilUTC}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload: CustomEventData & { [key: string]: unknown } = {
|
const payload: CustomEventData & { [key: string]: unknown } = {
|
||||||
group_id,
|
group_id,
|
||||||
title,
|
title,
|
||||||
@@ -225,6 +250,8 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
event_type: type,
|
event_type: type,
|
||||||
is_active: 1,
|
is_active: 1,
|
||||||
created_by: 1,
|
created_by: 1,
|
||||||
|
recurrence_rule: recurrenceRule,
|
||||||
|
recurrence_end: recurrenceEnd,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'presentation') {
|
if (type === 'presentation') {
|
||||||
@@ -250,6 +277,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
});
|
});
|
||||||
res = await res.json();
|
res = await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
onSave(payload);
|
onSave(payload);
|
||||||
onClose(); // <--- Box nach erfolgreichem Speichern schließen
|
onClose(); // <--- Box nach erfolgreichem Speichern schließen
|
||||||
|
|||||||
@@ -143,34 +143,49 @@ class MediaType(enum.Enum):
|
|||||||
class Event(Base):
|
class Event(Base):
|
||||||
__tablename__ = 'events'
|
__tablename__ = 'events'
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
group_id = Column(Integer, ForeignKey(
|
group_id = Column(Integer, ForeignKey('client_groups.id'), nullable=False, index=True)
|
||||||
'client_groups.id'), nullable=False, index=True)
|
academic_period_id = Column(Integer, ForeignKey('academic_periods.id'), nullable=True, index=True) # Optional für Rückwärtskompatibilität
|
||||||
academic_period_id = Column(Integer, ForeignKey(
|
|
||||||
# Optional für Rückwärtskompatibilität
|
|
||||||
'academic_periods.id'), nullable=True, index=True)
|
|
||||||
title = Column(String(100), nullable=False)
|
title = Column(String(100), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
start = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
start = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
||||||
end = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
end = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
||||||
event_type = Column(Enum(EventType), nullable=False)
|
event_type = Column(Enum(EventType), nullable=False)
|
||||||
event_media_id = Column(Integer, ForeignKey(
|
event_media_id = Column(Integer, ForeignKey('event_media.id'), nullable=True)
|
||||||
'event_media.id'), nullable=True)
|
|
||||||
autoplay = Column(Boolean, nullable=True) # NEU
|
autoplay = Column(Boolean, nullable=True) # NEU
|
||||||
loop = Column(Boolean, nullable=True) # NEU
|
loop = Column(Boolean, nullable=True) # NEU
|
||||||
volume = Column(Float, nullable=True) # NEU
|
volume = Column(Float, nullable=True) # NEU
|
||||||
slideshow_interval = Column(Integer, nullable=True) # NEU
|
slideshow_interval = Column(Integer, nullable=True) # NEU
|
||||||
created_at = Column(TIMESTAMP(timezone=True),
|
# Recurrence fields
|
||||||
server_default=func.current_timestamp())
|
recurrence_rule = Column(String(255), nullable=True, index=True) # iCalendar RRULE string
|
||||||
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
|
recurrence_end = Column(TIMESTAMP(timezone=True), nullable=True, index=True) # When recurrence ends
|
||||||
), onupdate=func.current_timestamp())
|
# Whether recurrences should skip school holidays
|
||||||
|
skip_holidays = Column(Boolean, nullable=False, server_default='0')
|
||||||
|
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())
|
||||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||||
updated_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
updated_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
# Add relationships
|
# Add relationships
|
||||||
academic_period = relationship(
|
academic_period = relationship("AcademicPeriod", foreign_keys=[academic_period_id])
|
||||||
"AcademicPeriod", foreign_keys=[academic_period_id])
|
|
||||||
event_media = relationship("EventMedia", foreign_keys=[event_media_id])
|
event_media = relationship("EventMedia", foreign_keys=[event_media_id])
|
||||||
|
exceptions = relationship("EventException", back_populates="event", cascade="all, delete-orphan")
|
||||||
|
# --- EventException: Store exceptions/overrides for recurring events ---
|
||||||
|
class EventException(Base):
|
||||||
|
__tablename__ = 'event_exceptions'
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
event_id = Column(Integer, ForeignKey('events.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
exception_date = Column(Date, nullable=False, index=True) # Date of the exception/override
|
||||||
|
is_skipped = Column(Boolean, default=False, nullable=False) # If this occurrence is skipped
|
||||||
|
override_title = Column(String(100), nullable=True)
|
||||||
|
override_description = Column(Text, nullable=True)
|
||||||
|
override_start = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||||
|
override_end = Column(TIMESTAMP(timezone=True), 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())
|
||||||
|
|
||||||
|
event = relationship("Event", back_populates="exceptions")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class EventMedia(Base):
|
class EventMedia(Base):
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import os
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy.orm import sessionmaker, joinedload
|
from sqlalchemy.orm import sessionmaker, joinedload
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from models.models import Event, EventMedia
|
from models.models import Event, EventMedia, EventException
|
||||||
|
from dateutil.rrule import rrulestr
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
load_dotenv('/workspace/.env')
|
load_dotenv('/workspace/.env')
|
||||||
|
|
||||||
@@ -34,8 +36,37 @@ def get_active_events(start: datetime, end: datetime, group_id: int = None):
|
|||||||
|
|
||||||
formatted_events = []
|
formatted_events = []
|
||||||
for event in events:
|
for event in events:
|
||||||
formatted_event = format_event_with_media(event)
|
# If event has RRULE, expand into instances within [start, end]
|
||||||
formatted_events.append(formatted_event)
|
if event.recurrence_rule:
|
||||||
|
try:
|
||||||
|
r = rrulestr(event.recurrence_rule, dtstart=event.start)
|
||||||
|
# iterate occurrences within range
|
||||||
|
occ_starts = r.between(start, end, inc=True)
|
||||||
|
duration = (event.end - event.start) if (event.end and event.start) else None
|
||||||
|
for occ_start in occ_starts:
|
||||||
|
occ_end = (occ_start + duration) if duration else occ_start
|
||||||
|
# Apply exceptions
|
||||||
|
exc = session.query(EventException).filter(
|
||||||
|
EventException.event_id == event.id,
|
||||||
|
EventException.exception_date == occ_start.date()
|
||||||
|
).first()
|
||||||
|
if exc:
|
||||||
|
if exc.is_skipped:
|
||||||
|
continue
|
||||||
|
if exc.override_start:
|
||||||
|
occ_start = exc.override_start
|
||||||
|
if exc.override_end:
|
||||||
|
occ_end = exc.override_end
|
||||||
|
inst = format_event_with_media(event)
|
||||||
|
inst["start"] = occ_start.isoformat()
|
||||||
|
inst["end"] = occ_end.isoformat()
|
||||||
|
inst["occurrence_of_id"] = event.id
|
||||||
|
formatted_events.append(inst)
|
||||||
|
except Exception:
|
||||||
|
# On parse error, fall back to single event formatting
|
||||||
|
formatted_events.append(format_event_with_media(event))
|
||||||
|
else:
|
||||||
|
formatted_events.append(format_event_with_media(event))
|
||||||
|
|
||||||
return formatted_events
|
return formatted_events
|
||||||
finally:
|
finally:
|
||||||
@@ -50,6 +81,9 @@ def format_event_with_media(event):
|
|||||||
"start": str(event.start),
|
"start": str(event.start),
|
||||||
"end": str(event.end),
|
"end": str(event.end),
|
||||||
"group_id": event.group_id,
|
"group_id": event.group_id,
|
||||||
|
# Carry recurrence metadata for consumers if needed
|
||||||
|
"recurrence_rule": getattr(event, "recurrence_rule", None),
|
||||||
|
"recurrence_end": (event.recurrence_end.isoformat() if getattr(event, "recurrence_end", None) else None),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Now you can directly access event.event_media
|
# Now you can directly access event.event_media
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ paho-mqtt
|
|||||||
sqlalchemy
|
sqlalchemy
|
||||||
pymysql
|
pymysql
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
python-dateutil
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add skip_holidays to events
|
||||||
|
|
||||||
|
Revision ID: 12ab34cd56ef
|
||||||
|
Revises: 2b627d0885c3
|
||||||
|
Create Date: 2025-10-12
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '12ab34cd56ef'
|
||||||
|
down_revision = '2b627d0885c3'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('events', sa.Column('skip_holidays', sa.Boolean(), nullable=False, server_default=sa.text('0')))
|
||||||
|
# Optional: create index if queries need it
|
||||||
|
# op.create_index('ix_events_skip_holidays', 'events', ['skip_holidays'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Optional: drop index
|
||||||
|
# op.drop_index('ix_events_skip_holidays', table_name='events')
|
||||||
|
op.drop_column('events', 'skip_holidays')
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Add recurrence fields to events and event_exceptions table
|
||||||
|
|
||||||
|
Revision ID: 15c357c0cf31
|
||||||
|
Revises: b5a6c3d4e7f8
|
||||||
|
Create Date: 2025-10-12 05:24:43.936743
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '15c357c0cf31'
|
||||||
|
down_revision: Union[str, None] = 'b5a6c3d4e7f8'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('event_exceptions',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('event_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('exception_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('is_skipped', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('override_title', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('override_description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('override_start', sa.TIMESTAMP(timezone=True), nullable=True),
|
||||||
|
sa.Column('override_end', sa.TIMESTAMP(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_event_exceptions_event_id'), 'event_exceptions', ['event_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_event_exceptions_exception_date'), 'event_exceptions', ['exception_date'], unique=False)
|
||||||
|
op.drop_index(op.f('ix_conv_source_event_media_id'), table_name='conversions')
|
||||||
|
op.create_index(op.f('ix_conversions_source_event_media_id'), 'conversions', ['source_event_media_id'], unique=False)
|
||||||
|
op.add_column('events', sa.Column('recurrence_rule', sa.String(length=255), nullable=True))
|
||||||
|
op.add_column('events', sa.Column('recurrence_end', sa.TIMESTAMP(timezone=True), nullable=True))
|
||||||
|
op.create_index(op.f('ix_events_recurrence_end'), 'events', ['recurrence_end'], unique=False)
|
||||||
|
op.create_index(op.f('ix_events_recurrence_rule'), 'events', ['recurrence_rule'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_events_recurrence_rule'), table_name='events')
|
||||||
|
op.drop_index(op.f('ix_events_recurrence_end'), table_name='events')
|
||||||
|
op.drop_column('events', 'recurrence_end')
|
||||||
|
op.drop_column('events', 'recurrence_rule')
|
||||||
|
op.drop_index(op.f('ix_conversions_source_event_media_id'), table_name='conversions')
|
||||||
|
op.create_index(op.f('ix_conv_source_event_media_id'), 'conversions', ['source_event_media_id'], unique=False)
|
||||||
|
op.drop_index(op.f('ix_event_exceptions_exception_date'), table_name='event_exceptions')
|
||||||
|
op.drop_index(op.f('ix_event_exceptions_event_id'), table_name='event_exceptions')
|
||||||
|
op.drop_table('event_exceptions')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -9,3 +9,4 @@ gunicorn
|
|||||||
redis>=5.0.1
|
redis>=5.0.1
|
||||||
rq>=1.16.2
|
rq>=1.16.2
|
||||||
requests>=2.32.3
|
requests>=2.32.3
|
||||||
|
python-dateutil>=2.9.0.post0
|
||||||
|
|||||||
113
server/routes/event_exceptions.py
Normal file
113
server/routes/event_exceptions.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from server.database import Session
|
||||||
|
from models.models import EventException, Event
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
event_exceptions_bp = Blueprint("event_exceptions", __name__, url_prefix="/api/event_exceptions")
|
||||||
|
|
||||||
|
|
||||||
|
@event_exceptions_bp.route("", methods=["POST"])
|
||||||
|
def create_exception():
|
||||||
|
data = request.json
|
||||||
|
session = Session()
|
||||||
|
# required: event_id, exception_date
|
||||||
|
required = ["event_id", "exception_date"]
|
||||||
|
for f in required:
|
||||||
|
if f not in data:
|
||||||
|
return jsonify({"error": f"Missing field: {f}"}), 400
|
||||||
|
|
||||||
|
# Validate event exists
|
||||||
|
event = session.query(Event).filter_by(id=data["event_id"]).first()
|
||||||
|
if not event:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Event not found"}), 404
|
||||||
|
|
||||||
|
exc_date = datetime.fromisoformat(data["exception_date"]).date() if isinstance(data["exception_date"], str) else data["exception_date"]
|
||||||
|
# Check if an exception for this event and date already exists
|
||||||
|
existing_exc = session.query(EventException).filter_by(event_id=event.id, exception_date=exc_date).first()
|
||||||
|
if existing_exc:
|
||||||
|
# Optionally, update the existing exception fields if needed
|
||||||
|
existing_exc.is_skipped = bool(data.get("is_skipped", existing_exc.is_skipped))
|
||||||
|
existing_exc.override_title = data.get("override_title", existing_exc.override_title)
|
||||||
|
existing_exc.override_description = data.get("override_description", existing_exc.override_description)
|
||||||
|
existing_exc.override_start = datetime.fromisoformat(data["override_start"]) if data.get("override_start") else existing_exc.override_start
|
||||||
|
existing_exc.override_end = datetime.fromisoformat(data["override_end"]) if data.get("override_end") else existing_exc.override_end
|
||||||
|
session.commit()
|
||||||
|
return jsonify({"success": True, "id": existing_exc.id, "updated": True})
|
||||||
|
# Otherwise, create a new exception
|
||||||
|
exc = EventException(
|
||||||
|
event_id=event.id,
|
||||||
|
exception_date=exc_date,
|
||||||
|
is_skipped=bool(data.get("is_skipped", False)),
|
||||||
|
override_title=data.get("override_title"),
|
||||||
|
override_description=data.get("override_description"),
|
||||||
|
override_start=(datetime.fromisoformat(data["override_start"]) if data.get("override_start") else None),
|
||||||
|
override_end=(datetime.fromisoformat(data["override_end"]) if data.get("override_end") else None),
|
||||||
|
)
|
||||||
|
session.add(exc)
|
||||||
|
session.commit()
|
||||||
|
return jsonify({"success": True, "id": exc.id})
|
||||||
|
|
||||||
|
|
||||||
|
@event_exceptions_bp.route("/<exc_id>", methods=["PUT"])
|
||||||
|
def update_exception(exc_id):
|
||||||
|
data = request.json
|
||||||
|
session = Session()
|
||||||
|
exc = session.query(EventException).filter_by(id=exc_id).first()
|
||||||
|
if not exc:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Exception not found"}), 404
|
||||||
|
|
||||||
|
if "exception_date" in data:
|
||||||
|
exc.exception_date = datetime.fromisoformat(data["exception_date"]).date()
|
||||||
|
if "is_skipped" in data:
|
||||||
|
exc.is_skipped = bool(data["is_skipped"])
|
||||||
|
if "override_title" in data:
|
||||||
|
exc.override_title = data.get("override_title")
|
||||||
|
if "override_description" in data:
|
||||||
|
exc.override_description = data.get("override_description")
|
||||||
|
if "override_start" in data:
|
||||||
|
exc.override_start = datetime.fromisoformat(data["override_start"]) if data.get("override_start") else None
|
||||||
|
if "override_end" in data:
|
||||||
|
exc.override_end = datetime.fromisoformat(data["override_end"]) if data.get("override_end") else None
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@event_exceptions_bp.route("/<exc_id>", methods=["DELETE"])
|
||||||
|
def delete_exception(exc_id):
|
||||||
|
session = Session()
|
||||||
|
exc = session.query(EventException).filter_by(id=exc_id).first()
|
||||||
|
if not exc:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Exception not found"}), 404
|
||||||
|
session.delete(exc)
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@event_exceptions_bp.route("", methods=["GET"])
|
||||||
|
def list_exceptions():
|
||||||
|
session = Session()
|
||||||
|
event_id = request.args.get("event_id")
|
||||||
|
q = session.query(EventException)
|
||||||
|
if event_id:
|
||||||
|
q = q.filter(EventException.event_id == int(event_id))
|
||||||
|
rows = q.all()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
out.append({
|
||||||
|
"id": r.id,
|
||||||
|
"event_id": r.event_id,
|
||||||
|
"exception_date": r.exception_date.isoformat(),
|
||||||
|
"is_skipped": r.is_skipped,
|
||||||
|
"override_title": r.override_title,
|
||||||
|
"override_description": r.override_description,
|
||||||
|
"override_start": r.override_start.isoformat() if r.override_start else None,
|
||||||
|
"override_end": r.override_end.isoformat() if r.override_end else None,
|
||||||
|
})
|
||||||
|
session.close()
|
||||||
|
return jsonify(out)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from server.database import Session
|
from server.database import Session
|
||||||
from models.models import Event, EventMedia, MediaType
|
from models.models import Event, EventMedia, MediaType, EventException
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
from dateutil.rrule import rrulestr
|
||||||
|
from dateutil.tz import UTC
|
||||||
import sys
|
import sys
|
||||||
sys.path.append('/workspace')
|
sys.path.append('/workspace')
|
||||||
|
|
||||||
@@ -28,6 +30,8 @@ def get_events():
|
|||||||
group_id = request.args.get("group_id")
|
group_id = request.args.get("group_id")
|
||||||
show_inactive = request.args.get(
|
show_inactive = request.args.get(
|
||||||
"show_inactive", "0") == "1" # Checkbox-Logik
|
"show_inactive", "0") == "1" # Checkbox-Logik
|
||||||
|
# Always let Syncfusion handle recurrence; do not expand on backend
|
||||||
|
expand = False
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
events_query = session.query(Event)
|
events_query = session.query(Event)
|
||||||
@@ -47,18 +51,48 @@ def get_events():
|
|||||||
if end_dt and end_dt < now and e.is_active:
|
if end_dt and end_dt < now and e.is_active:
|
||||||
e.is_active = False
|
e.is_active = False
|
||||||
session.commit()
|
session.commit()
|
||||||
if show_inactive or e.is_active:
|
if not (show_inactive or e.is_active):
|
||||||
result.append({
|
continue
|
||||||
"Id": str(e.id),
|
|
||||||
"GroupId": e.group_id,
|
# Gather exception dates for this event (for recurrenceException/EXDATE)
|
||||||
"Subject": e.title,
|
exception_dates = session.query(EventException).filter(
|
||||||
"StartTime": e.start.isoformat() if e.start else None,
|
EventException.event_id == e.id,
|
||||||
"EndTime": e.end.isoformat() if e.end else None,
|
EventException.is_skipped == True
|
||||||
"IsAllDay": False,
|
).all()
|
||||||
"MediaId": e.event_media_id,
|
# Syncfusion expects recurrenceException as comma-separated ISO strings (yyyy-MM-ddTHH:mm:ssZ)
|
||||||
"Type": e.event_type.value if e.event_type else None, # <-- Enum zu String!
|
# IMPORTANT: The time must match the event's occurrence start time. Use the event's start time-of-day.
|
||||||
"Icon": get_icon_for_type(e.event_type.value if e.event_type else None),
|
recurrence_exception = None
|
||||||
})
|
if exception_dates:
|
||||||
|
# Use event start time in UTC as baseline
|
||||||
|
base_start = e.start.astimezone(UTC) if e.start.tzinfo else e.start.replace(tzinfo=UTC)
|
||||||
|
tokens = []
|
||||||
|
for d in exception_dates:
|
||||||
|
exd = d.exception_date # date
|
||||||
|
occ_dt = datetime(
|
||||||
|
exd.year, exd.month, exd.day,
|
||||||
|
base_start.hour, base_start.minute, base_start.second,
|
||||||
|
tzinfo=UTC
|
||||||
|
)
|
||||||
|
tokens.append(occ_dt.strftime('%Y-%m-%dT%H:%M:%SZ'))
|
||||||
|
recurrence_exception = ','.join(tokens)
|
||||||
|
|
||||||
|
base_payload = {
|
||||||
|
"Id": str(e.id),
|
||||||
|
"GroupId": e.group_id,
|
||||||
|
"Subject": e.title,
|
||||||
|
"StartTime": e.start.isoformat() if e.start else None,
|
||||||
|
"EndTime": e.end.isoformat() if e.end else None,
|
||||||
|
"IsAllDay": False,
|
||||||
|
"MediaId": e.event_media_id,
|
||||||
|
"Type": e.event_type.value if e.event_type else None, # <-- Enum zu String!
|
||||||
|
"Icon": get_icon_for_type(e.event_type.value if e.event_type else None),
|
||||||
|
# Recurrence metadata
|
||||||
|
"RecurrenceRule": e.recurrence_rule,
|
||||||
|
"RecurrenceEnd": e.recurrence_end.isoformat() if e.recurrence_end else None,
|
||||||
|
"RecurrenceException": recurrence_exception,
|
||||||
|
"SkipHolidays": bool(getattr(e, 'skip_holidays', False)),
|
||||||
|
}
|
||||||
|
result.append(base_payload)
|
||||||
session.close()
|
session.close()
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
@@ -125,6 +159,9 @@ def create_event():
|
|||||||
if end.tzinfo is None:
|
if end.tzinfo is None:
|
||||||
end = end.astimezone(timezone.utc)
|
end = end.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
# Determine skip_holidays from either camelCase or snake_case
|
||||||
|
skip_holidays_val = bool(data.get("skipHolidays")) or bool(data.get("skip_holidays"))
|
||||||
|
|
||||||
# Event anlegen
|
# Event anlegen
|
||||||
event = Event(
|
event = Event(
|
||||||
group_id=data["group_id"],
|
group_id=data["group_id"],
|
||||||
@@ -134,12 +171,51 @@ def create_event():
|
|||||||
end=end,
|
end=end,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
event_media_id=event_media_id,
|
event_media_id=event_media_id,
|
||||||
slideshow_interval=slideshow_interval,
|
slideshow_interval=slideshow_interval,
|
||||||
created_by=created_by # <--- HIER hinzugefügt
|
created_by=created_by,
|
||||||
|
# Recurrence
|
||||||
|
recurrence_rule=data.get("recurrence_rule"),
|
||||||
|
skip_holidays=skip_holidays_val,
|
||||||
|
recurrence_end=(datetime.fromisoformat(data["recurrence_end"]) if data.get("recurrence_end") else None),
|
||||||
)
|
)
|
||||||
session.add(event)
|
session.add(event)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
# --- Holiday exception creation (backend) ---
|
||||||
|
def regenerate_event_exceptions(ev: Event):
|
||||||
|
from models.models import SchoolHoliday, EventException
|
||||||
|
from dateutil.rrule import rrulestr
|
||||||
|
from dateutil.tz import UTC
|
||||||
|
# Clear existing exceptions for this event
|
||||||
|
session.query(EventException).filter_by(event_id=ev.id).delete()
|
||||||
|
session.commit()
|
||||||
|
if not (ev.skip_holidays and ev.recurrence_rule):
|
||||||
|
return
|
||||||
|
# Get holidays
|
||||||
|
holidays = session.query(SchoolHoliday).all()
|
||||||
|
dtstart = ev.start.astimezone(UTC)
|
||||||
|
r = rrulestr(ev.recurrence_rule, dtstart=dtstart)
|
||||||
|
window_start = dtstart
|
||||||
|
window_end = ev.recurrence_end.astimezone(UTC) if ev.recurrence_end else dtstart.replace(year=dtstart.year + 1)
|
||||||
|
# Build set of all holiday dates (inclusive)
|
||||||
|
holiday_dates = set()
|
||||||
|
for h in holidays:
|
||||||
|
hs = h.start_date
|
||||||
|
he = h.end_date
|
||||||
|
d = hs
|
||||||
|
while d <= he:
|
||||||
|
holiday_dates.add(d)
|
||||||
|
d = d + timedelta(days=1)
|
||||||
|
# Create exceptions for occurrences on holiday dates
|
||||||
|
for occ_start in r.between(window_start, window_end, inc=True):
|
||||||
|
occ_date = occ_start.date()
|
||||||
|
if occ_date in holiday_dates:
|
||||||
|
session.add(EventException(event_id=ev.id, exception_date=occ_date, is_skipped=True))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
regenerate_event_exceptions(event)
|
||||||
|
|
||||||
return jsonify({"success": True, "event_id": event.id})
|
return jsonify({"success": True, "event_id": event.id})
|
||||||
|
|
||||||
|
|
||||||
@@ -160,10 +236,63 @@ def update_event(event_id):
|
|||||||
data["end"]) if "end" in data else event.end
|
data["end"]) if "end" in data else event.end
|
||||||
event.event_type = data.get("event_type", event.event_type)
|
event.event_type = data.get("event_type", event.event_type)
|
||||||
event.event_media_id = data.get("event_media_id", event.event_media_id)
|
event.event_media_id = data.get("event_media_id", event.event_media_id)
|
||||||
event.slideshow_interval = data.get(
|
event.slideshow_interval = data.get("slideshow_interval", event.slideshow_interval)
|
||||||
"slideshow_interval", event.slideshow_interval)
|
|
||||||
event.created_by = data.get("created_by", event.created_by)
|
event.created_by = data.get("created_by", event.created_by)
|
||||||
|
# Track previous values to decide on exception regeneration
|
||||||
|
prev_rule = event.recurrence_rule
|
||||||
|
prev_end = event.recurrence_end
|
||||||
|
prev_skip = bool(getattr(event, 'skip_holidays', False))
|
||||||
|
|
||||||
|
# Recurrence updates
|
||||||
|
if "recurrence_rule" in data:
|
||||||
|
event.recurrence_rule = data.get("recurrence_rule")
|
||||||
|
if "recurrence_end" in data:
|
||||||
|
rec_end_val = data.get("recurrence_end")
|
||||||
|
event.recurrence_end = datetime.fromisoformat(rec_end_val) if rec_end_val else None
|
||||||
|
# Skip holidays can be updated independently
|
||||||
|
if "skipHolidays" in data or "skip_holidays" in data:
|
||||||
|
event.skip_holidays = bool(data.get("skipHolidays") or data.get("skip_holidays"))
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
# Regenerate exceptions if any relevant field changed
|
||||||
|
need_regen = (
|
||||||
|
prev_rule != event.recurrence_rule or
|
||||||
|
prev_end != event.recurrence_end or
|
||||||
|
prev_skip != bool(getattr(event, 'skip_holidays', False))
|
||||||
|
)
|
||||||
|
if need_regen:
|
||||||
|
# Re-use helper from create route
|
||||||
|
def regenerate_event_exceptions(ev: Event):
|
||||||
|
from models.models import SchoolHoliday, EventException
|
||||||
|
from dateutil.rrule import rrulestr
|
||||||
|
from dateutil.tz import UTC
|
||||||
|
# Clear existing exceptions
|
||||||
|
session.query(EventException).filter_by(event_id=ev.id).delete()
|
||||||
|
session.commit()
|
||||||
|
if not (ev.skip_holidays and ev.recurrence_rule):
|
||||||
|
return
|
||||||
|
holidays = session.query(SchoolHoliday).all()
|
||||||
|
dtstart = ev.start.astimezone(UTC)
|
||||||
|
r = rrulestr(ev.recurrence_rule, dtstart=dtstart)
|
||||||
|
window_start = dtstart
|
||||||
|
window_end = ev.recurrence_end.astimezone(UTC) if ev.recurrence_end else dtstart.replace(year=dtstart.year + 1)
|
||||||
|
holiday_dates = set()
|
||||||
|
for h in holidays:
|
||||||
|
hs = h.start_date
|
||||||
|
he = h.end_date
|
||||||
|
d = hs
|
||||||
|
while d <= he:
|
||||||
|
holiday_dates.add(d)
|
||||||
|
d = d + timedelta(days=1)
|
||||||
|
for occ_start in r.between(window_start, window_end, inc=True):
|
||||||
|
occ_date = occ_start.date()
|
||||||
|
if occ_date in holiday_dates:
|
||||||
|
session.add(EventException(event_id=ev.id, exception_date=occ_date, is_skipped=True))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
regenerate_event_exceptions(event)
|
||||||
|
|
||||||
event_id_return = event.id # <-- ID vor session.close() speichern!
|
event_id_return = event.id # <-- ID vor session.close() speichern!
|
||||||
session.close()
|
session.close()
|
||||||
return jsonify({"success": True, "event_id": event_id_return})
|
return jsonify({"success": True, "event_id": event_id_return})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from server.routes.eventmedia import eventmedia_bp
|
from server.routes.eventmedia import eventmedia_bp
|
||||||
from server.routes.files import files_bp
|
from server.routes.files import files_bp
|
||||||
from server.routes.events import events_bp
|
from server.routes.events import events_bp
|
||||||
|
from server.routes.event_exceptions import event_exceptions_bp
|
||||||
from server.routes.conversions import conversions_bp
|
from server.routes.conversions import conversions_bp
|
||||||
from server.routes.holidays import holidays_bp
|
from server.routes.holidays import holidays_bp
|
||||||
from server.routes.academic_periods import academic_periods_bp
|
from server.routes.academic_periods import academic_periods_bp
|
||||||
@@ -21,6 +22,7 @@ app = Flask(__name__)
|
|||||||
app.register_blueprint(clients_bp)
|
app.register_blueprint(clients_bp)
|
||||||
app.register_blueprint(groups_bp)
|
app.register_blueprint(groups_bp)
|
||||||
app.register_blueprint(events_bp)
|
app.register_blueprint(events_bp)
|
||||||
|
app.register_blueprint(event_exceptions_bp)
|
||||||
app.register_blueprint(eventmedia_bp)
|
app.register_blueprint(eventmedia_bp)
|
||||||
app.register_blueprint(files_bp)
|
app.register_blueprint(files_bp)
|
||||||
app.register_blueprint(holidays_bp)
|
app.register_blueprint(holidays_bp)
|
||||||
|
|||||||
Reference in New Issue
Block a user