"""
Pydantic schemas for request validation and response serialisation.
Every schema is a :class:`pydantic.BaseModel` subclass. Schemas are
grouped by domain:
- **Auth** – :class:`LoginRequest`, :class:`RefreshRequest`,
:class:`LogoutRequest`
- **User** – :class:`UserOut`, :class:`UserCreate`, :class:`UserUpdate`,
:class:`UserListElem`, :class:`UserTodo`, :class:`UserTodoList`
- **Song** – :class:`SongIn`, :class:`SongOut`, :class:`SongCandidateOut`,
:class:`SongFeedbackBase`, :class:`SongFeedbackIn`,
:class:`SongStatistics`
- **Rehearsal** – :class:`RehListElem`, :class:`NewRehDict`,
:class:`RehTodoOut`
- **Gig** – :class:`GigIn`, :class:`GigOut`, :class:`GigSetlistOut`,
:class:`GigStatistics`, :class:`SeasonStatistics`
- **Survey** – :class:`SurveyIn`, :class:`SurveyList`,
:class:`SurveyQuestionOut`, :class:`SurveyFeedbackOut`
- **Password** – :class:`PasswordUpdateRequest`,
:class:`PasswordResetRequest`
"""
# libre-stage - Band rehearsal and gig management software
# Copyright (C) 2026 libre-stage contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from pydantic import BaseModel, Field, field_validator, computed_field, model_validator, EmailStr
from typing import Optional, Union, List, Dict
from enum import Enum
from datetime import time, date, datetime
[Doku]
class LoginRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=8, max_length=128)
[Doku]
@field_validator('username', mode='before')
@classmethod
def strip_username(cls, v):
if isinstance(v, str):
return v.strip()
return v
[Doku]
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
[Doku]
class RefreshRequest(BaseModel):
refresh_token: str
[Doku]
class LogoutRequest(BaseModel):
refresh_token: Optional[str] = None
[Doku]
class UserGroup(str, Enum):
admin = "admin"
editor = "editor"
user = "user"
[Doku]
class UserStatus(str, Enum):
active = "active"
deactivated = "deactivated"
[Doku]
class UserCreate(BaseModel):
user_name: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$')
clear_name: Optional[str] = None
email: EmailStr
user_pw: str
user_group: UserGroup
musician: bool = False
is_singer: bool = False
status: UserStatus = UserStatus.active
[Doku]
class UserOut(BaseModel):
id: int
user_name: str
user_group: UserGroup
email: EmailStr
clear_name: str
musician: bool
is_singer: bool
mm_username: Optional[str] = ""
status: UserStatus = UserStatus.active
model_config = {"from_attributes": True} # <--- das ist essenziell!
[Doku]
class UserTodo(BaseModel):
id: int
todo: str
user_name: str
song_title: str
song_interpret: str
done: bool
dt: Optional[datetime]
[Doku]
class SongForFeedback(BaseModel):
id: int
title: str
interpret: str
status: str
[Doku]
class SurveyForFeedback(BaseModel):
id: int
kind_of_survey: str
rf_survey: str
release_date: Optional[str] = None
[Doku]
class UserTodoList(BaseModel):
todo: List[UserTodo] = Field(default_factory=list)
songs_to_feedback: List[SongForFeedback] = Field(default_factory=list)
surveys_to_feedback: List[SurveyForFeedback] = Field(default_factory=list)
[Doku]
class PasswordUpdateRequest(BaseModel):
user_id: int
old_password: str
new_password: str
[Doku]
class PasswordResetRequest(BaseModel):
new_password: str = Field(..., min_length=8, max_length=128)
[Doku]
class SoftConfigOption(BaseModel):
key: Optional[str] = None
label: str
model_config = {"extra": "forbid"}
[Doku]
class SoftConfigUpdateIn(BaseModel):
genres: List[Union[str, SoftConfigOption]]
gigTypes: List[Union[str, SoftConfigOption]]
songStatuses: List[Union[str, SoftConfigOption]]
gigStatuses: List[Union[str, SoftConfigOption]]
tonekeys: List[Union[str, SoftConfigOption]]
rehearsalSongStatuses: List[Union[str, SoftConfigOption]]
setlist_timing: List[Dict[str, int]]
model_config = {"extra": "forbid"}
[Doku]
class SoftConfigOut(BaseModel):
genres: List[SoftConfigOption]
gigTypes: List[SoftConfigOption]
songStatuses: List[SoftConfigOption]
gigStatuses: List[SoftConfigOption]
tonekeys: List[SoftConfigOption]
rehearsalSongStatuses: List[str]
setlist_timing: List[Dict[str, int]]
[Doku]
class SoftConfigAdminResponse(BaseModel):
data: SoftConfigOut
meta: SoftConfigMeta
[Doku]
class SoftConfigUpdateResponse(BaseModel):
message: str
updatedKeys: List[str]
data: SoftConfigOut
[Doku]
class SongIn(BaseModel):
title: str = Field(..., max_length=200)
interpret: str = Field(..., max_length=200)
genre: str= Field(..., max_length=100)
singer_background: Optional[str] = Field(None, max_length=200)
singer_lead: Optional[str] = Field(None, max_length=200)
composer: Optional[str] = Field(None, max_length=500)
texter: Optional[str] = Field(None, max_length=500)
publisher: Optional[str] = Field(None, max_length=500)
arrangement: Optional[str] = Field(None, max_length=500)
tone_key: Optional[str] = Field(None, max_length=20)
status: str
comment: Optional[str] = Field(None, max_length=5000)
ytlink: Optional[str] = None
brass: Optional[Union[int,str]] = None # erlaubt beides
text: Optional[str] = None # falls in Model vorhanden
id: int = None
duration: Optional[Union[time, str]] = None
[Doku]
@field_validator("duration", mode="before")
@classmethod
def parse_duration(cls, v):
if v in (None, "", "None"):
return None
if isinstance(v, time):
return v # pragma: no cover
h, m, s = map(int, v.split(":"))
return time(hour=h, minute=m, second=s)
[Doku]
class SongFeedbackIn(BaseModel):
song_id: int
user_id: int
feedback: str
[Doku]
class SongFeedbackBase(BaseModel):
song_id: int
user_id: int
feedback: str
model_config = {"from_attributes": True}
[Doku]
class SongFeedbackSummary(BaseModel):
song_id: int
total_votes: int = 0
yes_votes: int = 0
no_votes: int = 0
abstain_votes: int = 0
unknown_votes: int = 0
[Doku]
class SongCandidateOut(BaseModel):
id: int
title: str
interpret: str
genre: Optional[str] = None
singer_lead: Optional[str] = None
singer_background: Optional[str] = None
composer: Optional[str] = None
tone_key: Optional[str] = None
status: Optional[str] = None
ytlink: Optional[str] = None
brass: Optional[int] = None
duration: Optional[time] = None
feedbacks: list[SongFeedbackBase] = Field(default_factory=list)
model_config = {"from_attributes": True}
[Doku]
class SongOut(BaseModel):
title: Optional[str] = None
interpret: Optional[str] = None
genre: Optional[str] = None
singer_background: Optional[str] = None
singer_lead: Optional[str] = None
composer: Optional[str] = None
texter: Optional[str] = None
publisher: Optional[str] = None
arrangement: Optional[str] = None
tone_key: Optional[str] = None
status: Optional[str] = None
comment: Optional[str] = None
ytlink: Optional[str] = None
duration: Optional[time] = None
brass: Optional[int] = None
id: int = None
@computed_field
@property
def singer_lead_short(self) -> str:
# Die Logik aus deinem dict
return self.singer_lead.strip().split("+")[0].split(" ")[0].split(",")[0].strip() if self.singer_lead else ""
@computed_field
@property
def duration_formatted(self) -> str:
if isinstance(self.duration, time):
return self.duration.strftime("%M:%S")
return "00:00"
model_config = {"from_attributes": True} # <--- das ist essenziell!
[Doku]
class SongScrawlOut(BaseModel):
recording_id: Optional[str] = None
work_id: Optional[str] = None
duration: Optional[str] = None
ytlink: Optional[str] = None
composers: List[str] = Field(default_factory=list)
lyricists: List[str] = Field(default_factory=list)
composer: Optional[str] = None
texter: Optional[str] = None
[Doku]
class GigIn(BaseModel):
name: str
datum: date
venue: Optional[str] = None
kind_of_gig: str
doors: Optional[Union[time,str]] = None
begin: Optional[Union[time,str]] = None
end: Optional[Union[time,str]] = None
organizer: Optional[str] = None
status: Optional[str] = None
id: int = None
publish: Optional[int] = None
[Doku]
@field_validator("datum", mode="before")
@classmethod
def parse_date(cls, v):
print (f"Parse: {v}")
if v in (None, "", "None"):
return None # pragma: no cover
if isinstance(v, date):
return v # pragma: no cover
try:
y, m, d = map(int, v.split("-")) # pragma: no cover
print(date(year=y, month=m, day=d)) # pragma: no cover
return date(year=y, month=m, day=d) # pragma: no cover
except Exception as exc: # pragma: no cover
raise ValueError("date muss im Format YYYY-MM-DD sein") from exc # pragma: no cover
[Doku]
@field_validator("doors", "begin", "end", mode="before")
@classmethod
def parse_time_field(cls, v):
if v in (None, "", "None"): # pragma: no cover
return None # pragma: no cover
if isinstance(v, time): # pragma: no cover
return v # pragma: no cover
try: # pragma: no cover
h, m, s = map(int, v.split(":")) # pragma: no cover
return time(hour=h, minute=m, second=s) # pragma: no cover
except Exception as exc: # pragma: no cover
raise ValueError("doors, begin, end müssen im Format HH:MM:SS (z.B. 09:30:00) sein") from exc # pragma: no cover
[Doku]
class NewGig(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
datum: date
venue: Optional[str] = Field(None, max_length=300)
kind_of_gig: str = Field(..., max_length=300)
begin: Union[time, str] # Pflichtfeld
doors: Optional[Union[time, str]] = None
end: Optional[Union[time, str]] = None
organizer: Optional[str] = None
status: Optional[str] = None
publish: Optional[int] = 0
[Doku]
@model_validator(mode="before")
@classmethod
def set_default_times(cls, values):
begin = values.get("begin")
# begin zu time konvertieren falls String
if isinstance(begin, str) and begin:
parts = begin.split(":")
h = int(parts[0])
m = int(parts[1])
s = int(parts[2]) if len(parts) == 3 else 0 # Sekunden optional
begin_time = time(hour=h, minute=m, second=s)
values["begin"] = begin_time
elif isinstance(begin, time):
begin_time = begin
else:
return values
# doors: 1 Stunde vor begin
if not values.get("doors"):
doors_hour = (begin_time.hour - 1) % 24
values["doors"] = time(hour=doors_hour, minute=begin_time.minute, second=begin_time.second)
else:
if isinstance(values["doors"], str):
parts = values["doors"].split(":")
h = int(parts[0])
m = int(parts[1])
s = int(parts[2]) if len(parts) == 3 else 0 # Sekunden optional
values["doors"] = time(hour=h, minute=m, second=s)
# end: 2 Stunden nach begin
if not values.get("end"):
end_hour = (begin_time.hour + 2) % 24
values["end"] = time(hour=end_hour, minute=begin_time.minute, second=begin_time.second)
else:
if isinstance(values["end"], str):
parts = values["end"].split(":")
h = int(parts[0])
m = int(parts[1])
s = int(parts[2]) if len(parts) == 3 else 0 # Sekunden optional
values["end"] = time(hour=h, minute=m, second=s)
return values
[Doku]
class GigOut(BaseModel):
name: str = None
datum: date = None
venue: Optional[str] = None
organizer: Optional[str] = None
kind_of_gig: Optional[str] = None
doors: Optional[time] = None
begin: Optional[time] = None
end: Optional[time] = None
status: Optional[str] = None
id: int = None
publish: int = None
model_config = {"from_attributes": True} # <--- das ist essenziell!
[Doku]
class GigScheduleItemIn(BaseModel):
item_datetime: datetime
was: str = Field(..., max_length=512)
wer: str = Field(..., max_length=512)
wo: str = Field(..., max_length=512)
[Doku]
class GigScheduleItemOut(BaseModel):
id: Optional[int] = None
gig_id: int
item_datetime: datetime
was: str
wer: str
wo: str
is_fixed: bool = False
model_config = {"from_attributes": True}
[Doku]
class GigScheduleOut(BaseModel):
items: List[GigScheduleItemOut] = Field(default_factory=list)
[Doku]
class GigScheduleBulkItemIn(BaseModel):
id: Optional[int] = None
item_datetime: datetime
was: str = Field(..., max_length=512)
wer: str = Field(..., max_length=512)
wo: str = Field(..., max_length=512)
[Doku]
class GigScheduleBulkUpdateIn(BaseModel):
items: List[GigScheduleBulkItemIn] = Field(default_factory=list)
[Doku]
class SetOut(BaseModel):
id: int
position: int
pause: time | None
setlist_name: str | None
songs: list[SongOut]
model_config = {"from_attributes": True}
[Doku]
class SongInSetOut(BaseModel):
id: int
setsong_id: Optional[int] = None
song_id: int
position: Optional[int] = None
title: str
duration: Optional[str]
singer_lead: Optional[str]
singer_background: Optional[str]
interpret: Optional[str]
genre: Optional[str] = None
tone_key: Optional[str] = None
ytlink: Optional[str] = None
comment: Optional[str] = None
brass: Optional[int]
status: Optional[str] = None
[Doku]
class SetInGigOut(BaseModel):
id: Optional[int] = None
gigset_id: Optional[int] = None
set_id: Optional[int] = None
set_name: Optional[str] = ''
pause: Optional[str] = None
setlist_name: Optional[str] = ''
songs: List[SongInSetOut]
[Doku]
class GigSetlistTimingOut(BaseModel):
schedule: Dict[str, List[str]] = Field(default_factory=dict)
pause_before: Dict[str, int] = Field(default_factory=dict)
set_end: Dict[str, str] = Field(default_factory=dict)
[Doku]
class GigSetlistOut(BaseModel):
id: int
name: str
datum: Optional[str]
organizer: Optional[str] = None
kind_of_gig: Optional[str] = None
venue: Optional[str] = None
doors: Optional[str] = None
begin: Optional[str] = None
end: Optional[str] = None
status: Optional[str] = None
publish: Optional[str] = None
sets: List[SetInGigOut]
timing: Optional[GigSetlistTimingOut] = None
model_config = {"from_attributes": True}
[Doku]
class SongInSetLM(BaseModel):
id: int
title: str
interpret: str
position: int
tone_key: Optional[str] = None
comment: Optional[str] = None
uebersprungen: Optional[bool] = None
eingeschoben: Optional[bool] = None
feedback: Optional[int] = None
model_config = {"from_attributes": True}
[Doku]
class SongInSetLMUpdate(BaseModel):
id: int
uebersprungen: Optional[bool] = None
eingeschoben: Optional[bool] = None
feedback: Optional[int] = None
model_config = {"from_attributes": True}
[Doku]
class SetInGigLM(BaseModel):
id: int
position: int
pause: Optional[str] = None
setlist_name: Optional[str] = None
songs: List[SongInSetLM]
model_config = {"from_attributes": True}
[Doku]
class GigSetListLiveMode(BaseModel):
id: int
name: str
datum: Optional[str]
doors: Optional[str] = None
begin: Optional[str] = None
end: Optional[str] = None
sets: List[SetInGigLM]
model_config = {"from_attributes": True} # <--- das ist essenziell!
[Doku]
class GetSetlistIn(BaseModel):
id: int
name: str
datum: Optional[str]
organizer: Optional[str] = None
kind_of_gig: Optional[str] = None
venue: Optional[str] = None
doors: Optional[str] = None
begin: Optional[str] = None
end: Optional[str] = None
status: Optional[str] = None
publish: Optional[str] = None
sets: List[SetInGigOut]
model_config = {"from_attributes": True} # <--- das ist essenziell!
[Doku]
class SongToSetIn:
gigId: int
songId: int
setId: int
position: int
[Doku]
class TodoInSong(BaseModel):
id: Optional[int]
id_song: int
id_reh: int
id_user: int
todo: str
dt: Optional[datetime]
done: bool
model_config = {"from_attributes": True}
[Doku]
class SongInReh(BaseModel):
id: Optional[int] = None
id_rehearsal: int
id_song: int
interpret: str
title: str
status: str
comment: Optional[str]
setlist_comment: Optional[str]
todo: Optional[str]
song_todos: Optional[List[TodoInSong]] = Field(default_factory=list)
done: bool
model_config = {"from_attributes": True}
[Doku]
class SongRehearsalHistoryTodo(BaseModel):
id: int
id_user: int
todo: str
done: bool
model_config = {"from_attributes": True}
[Doku]
class SongRehearsalHistoryEntry(BaseModel):
rehearsal_id: int
rehearsal_date: datetime
comment: Optional[str] = None
todo: Optional[str] = None
done: bool = False
rehearsal_comment: Optional[str] = None
todos: List[SongRehearsalHistoryTodo] = Field(default_factory=list)
model_config = {"from_attributes": True}
[Doku]
class GigPlayedEntry(BaseModel):
gig_id: int
gig_name: str
gig_date: str
feedback: Optional[int] = None
uebersprungen: Optional[bool] = None
eingeschoben: Optional[bool] = None
[Doku]
class CompanionSong(BaseModel):
song_id: int
title: str
interpret: str
count: int
[Doku]
class SongStatistics(BaseModel):
# Proben
rehearsal_count: int = 0
first_rehearsal: Optional[str] = None
last_rehearsal: Optional[str] = None
# Gigs
gig_count: int = 0
gigs_played: List[GigPlayedEntry] = Field(default_factory=list)
# Live-Mode Bewertungen
feedback_count: int = 0
feedback_avg: Optional[float] = None
feedback_distribution: dict = Field(default_factory=dict) # {1: x, 2: y, 3: z}
skipped_count: int = 0
inserted_count: int = 0
# Häufige Set-Begleiter
companion_songs: List[CompanionSong] = Field(default_factory=list)
[Doku]
class GigOverviewEntry(BaseModel):
gig_id: int
gig_name: str
gig_date: str
song_count: int
skipped_count: int
inserted_count: int
feedback_avg: Optional[float] = None
[Doku]
class TopSongEntry(BaseModel):
song_id: int
title: str
interpret: str
count: int # Wie oft in Setlisten dieser Saison
[Doku]
class GenreTimelinePoint(BaseModel):
label: str
date: Optional[str] = None
kind_of_gig: Optional[str] = None
genre_counts: dict = Field(default_factory=dict) # {genre: count}
total: int = 0
[Doku]
class GenrePaletteOut(BaseModel):
palette: dict = Field(default_factory=dict) # {genre: "#RRGGBB"}
[Doku]
class SeasonStatistics(BaseModel):
jahr: Optional[int] = None
gig_count: int = 0
played_gig_count: int = 0 # Anzahl gespielter (vergangener) Gigs
total_songs: int = 0 # Gesamtzahl Song-Einträge in allen Sets
unique_songs: int = 0 # Anzahl unique Songs
skipped_count: int = 0
inserted_count: int = 0
feedback_count: int = 0
feedback_avg: Optional[float] = None
feedback_distribution: dict = Field(default_factory=dict) # {1: x, 2: y, 3: z}
genre_distribution: dict = Field(default_factory=dict) # {genre: count}
genre_timeline: List[GenreTimelinePoint] = Field(default_factory=list)
top_songs: List[TopSongEntry] = Field(default_factory=list)
gigs_overview: List[GigOverviewEntry] = Field(default_factory=list)
[Doku]
class GigStatsSongEntry(BaseModel):
song_id: int
title: str
interpret: str
position: int
feedback: Optional[int] = None
uebersprungen: Optional[bool] = None
eingeschoben: Optional[bool] = None
[Doku]
class GigStatsSetEntry(BaseModel):
set_name: str
feedback_avg: Optional[float] = None
songs: List[GigStatsSongEntry] = Field(default_factory=list)
[Doku]
class GigStatistics(BaseModel):
gig_id: int
gig_name: str
gig_date: str
song_count: int = 0
skipped_count: int = 0
inserted_count: int = 0
feedback_count: int = 0
feedback_avg: Optional[float] = None
feedback_distribution: dict = Field(default_factory=dict) # {1: x, 2: y, 3: z}
genre_distribution: dict = Field(default_factory=dict) # {genre: count}
genre_timeline: List[GenreTimelinePoint] = Field(default_factory=list)
sets: List[GigStatsSetEntry] = Field(default_factory=list)
[Doku]
class RehListElem(BaseModel):
id: int
begin: datetime
end: Optional[datetime] = None
comment: Optional[str] = None
ical: Optional[str] = ""
songs: Optional[List[SongInReh]] = Field(default_factory=list)
model_config = {"from_attributes": True}
[Doku]
class NewRehDict(BaseModel):
begin: datetime
end: Optional[datetime] = None
comment: Optional[str]
[Doku]
@model_validator(mode="after")
def validate_time_range(self):
if self.end is not None and self.end <= self.begin:
raise ValueError("end must be after begin")
return self
model_config = {"from_attributes": True}
[Doku]
class UserListElem(BaseModel):
id: int
user_name: str
clear_name: str
email: str
[Doku]
class SurveyFeedbackOut(BaseModel):
id: Optional[int] = -1
id_sv_field: int
id_user: int
value: Optional[str] = None
comment: Optional[str] = ""
model_config = {"from_attributes": True}
[Doku]
class SurveyFieldsOut(BaseModel):
id: int
id_survey: int
field_text: str
feedbacks: List[SurveyFeedbackOut] = Field(default_factory=list)
model_config = {"from_attributes": True}
[Doku]
class SurveyQuestionOut(BaseModel):
id: int
kind_of_survey: str
rf_survey: str
released: bool
release_date: datetime
closed: bool
fields: List[SurveyFieldsOut]
user_created: int
model_config = {"from_attributes": True}
#
[Doku]
class SurveyList(BaseModel):
id: int
kind_of_survey: str
rf_survey: str
released: bool
closed: bool
release_date: datetime
user_created: int
model_config = {"from_attributes": True}
[Doku]
class SurveyFieldIn(BaseModel):
field_text: str
[Doku]
class SurveyIn(BaseModel):
kind_of_survey: str
rf_survey: str
released: bool
closed: bool
fields: List[SurveyFieldIn] # Nur die Feldtexte beim Anlegen
[Doku]
class PublicSongHistogram(BaseModel):
genre: str
count: int
[Doku]
class PublicDate(BaseModel):
Datum: str
Name: str
Ort: str
Einlass: Optional[str] = None
Beginn: Optional[str] = None
Ende: Optional[str] = None
Veranstalter: Optional[str] = None
Art: Optional[str] = None
[Doku]
@classmethod
def from_gig(cls, gig):
return cls(
Datum=gig.datum.strftime('%Y-%m-%d') if gig.datum else None,
Name=gig.name,
Ort=gig.venue or "",
Einlass=gig.doors.strftime('%H:%M') if gig.doors else None,
Beginn=gig.begin.strftime('%H:%M') if gig.begin else None,
Ende=gig.end.strftime('%H:%M') if gig.end else None,
Veranstalter=gig.organizer or "",
Art=gig.kind_of_gig or ""
)