feat: Space Booking System - MVP complet

Sistem web pentru rezervarea de birouri și săli de ședință
cu flux de aprobare administrativă.

Stack: FastAPI + Vue.js 3 + SQLite + TypeScript

Features implementate:
- Autentificare JWT + Self-registration cu email verification
- CRUD Spații, Utilizatori, Settings (Admin)
- Calendar interactiv (FullCalendar) cu drag-and-drop
- Creare rezervări cu validare (durată, program, overlap, max/zi)
- Rezervări recurente (săptămânal)
- Admin: aprobare/respingere/anulare cereri
- Admin: creare directă rezervări (bypass approval)
- Admin: editare orice rezervare
- User: editare/anulare rezervări proprii
- Notificări in-app (bell icon + dropdown)
- Notificări email (async SMTP cu BackgroundTasks)
- Jurnal acțiuni administrative (audit log)
- Rapoarte avansate (utilizare, top users, approval rate)
- Șabloane rezervări (booking templates)
- Atașamente fișiere (upload/download)
- Conflict warnings (verificare disponibilitate real-time)
- Integrare Google Calendar (OAuth2)
- Suport timezone (UTC storage + user preference)
- 225+ teste backend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-09 17:51:29 +00:00
commit df4031d99c
113 changed files with 24491 additions and 0 deletions

View File

@@ -0,0 +1 @@
# Schemas module

View File

@@ -0,0 +1,22 @@
"""Attachment schemas."""
from datetime import datetime
from pydantic import BaseModel
class AttachmentRead(BaseModel):
"""Attachment read schema."""
id: int
booking_id: int
filename: str
size: int
content_type: str
uploaded_by: int
uploader_name: str
created_at: datetime
class Config:
"""Pydantic config."""
from_attributes = True

View File

@@ -0,0 +1,21 @@
"""Audit log schemas."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict
class AuditLogRead(BaseModel):
"""Schema for reading audit log entries."""
id: int
action: str
user_id: int
user_name: str # From relationship
user_email: str # From relationship
target_type: str
target_id: int
details: Optional[dict[str, Any]] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,49 @@
"""Authentication schemas."""
import re
from pydantic import BaseModel, EmailStr, Field, field_validator
class LoginRequest(BaseModel):
"""Login request schema."""
email: EmailStr
password: str
class UserRegister(BaseModel):
"""User registration schema."""
email: EmailStr
password: str = Field(..., min_length=8)
confirm_password: str
full_name: str = Field(..., min_length=2, max_length=200)
organization: str = Field(..., min_length=2, max_length=200)
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
"""Validate password strength."""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not re.search(r"[A-Z]", v):
raise ValueError("Password must contain at least one uppercase letter")
if not re.search(r"[a-z]", v):
raise ValueError("Password must contain at least one lowercase letter")
if not re.search(r"[0-9]", v):
raise ValueError("Password must contain at least one digit")
return v
@field_validator("confirm_password")
@classmethod
def passwords_match(cls, v: str, info) -> str:
"""Ensure passwords match."""
if "password" in info.data and v != info.data["password"]:
raise ValueError("Passwords do not match")
return v
class EmailVerificationRequest(BaseModel):
"""Email verification request schema."""
token: str

View File

@@ -0,0 +1,244 @@
"""Booking schemas for request/response."""
from datetime import datetime, date
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class BookingCalendarPublic(BaseModel):
"""Public booking data for regular users (calendar view)."""
id: int
start_datetime: datetime
end_datetime: datetime
status: str
title: str
model_config = {"from_attributes": True}
class BookingCalendarAdmin(BaseModel):
"""Full booking data for admins (calendar view)."""
id: int
user_id: int
space_id: int
start_datetime: datetime
end_datetime: datetime
status: str
title: str
description: str | None
rejection_reason: str | None
cancellation_reason: str | None
approved_by: int | None
created_at: datetime
model_config = {"from_attributes": True}
class BookingCreate(BaseModel):
"""Schema for creating a new booking."""
space_id: int
start_datetime: datetime
end_datetime: datetime
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
class BookingResponse(BaseModel):
"""Schema for booking response after creation."""
id: int
user_id: int
space_id: int
start_datetime: datetime
end_datetime: datetime
status: str
title: str
description: str | None
created_at: datetime
# Timezone-aware formatted strings (optional, set by endpoint)
start_datetime_tz: Optional[str] = None
end_datetime_tz: Optional[str] = None
model_config = {"from_attributes": True}
@classmethod
def from_booking_with_timezone(cls, booking, user_timezone: str = "UTC"):
"""Create response with timezone conversion."""
from app.utils.timezone import format_datetime_tz
return cls(
id=booking.id,
user_id=booking.user_id,
space_id=booking.space_id,
start_datetime=booking.start_datetime,
end_datetime=booking.end_datetime,
status=booking.status,
title=booking.title,
description=booking.description,
created_at=booking.created_at,
start_datetime_tz=format_datetime_tz(booking.start_datetime, user_timezone),
end_datetime_tz=format_datetime_tz(booking.end_datetime, user_timezone)
)
class SpaceInBooking(BaseModel):
"""Space details embedded in booking response."""
id: int
name: str
type: str
model_config = {"from_attributes": True}
class BookingWithSpace(BaseModel):
"""Booking with associated space details for user's booking list."""
id: int
space_id: int
space: SpaceInBooking
start_datetime: datetime
end_datetime: datetime
status: str
title: str
description: str | None
created_at: datetime
model_config = {"from_attributes": True}
class UserInBooking(BaseModel):
"""User details embedded in booking response."""
id: int
full_name: str
email: str
organization: str | None
model_config = {"from_attributes": True}
class BookingPendingDetail(BaseModel):
"""Detailed booking information for admin pending list."""
id: int
space_id: int
space: SpaceInBooking
user_id: int
user: UserInBooking
start_datetime: datetime
end_datetime: datetime
status: str
title: str
description: str | None
created_at: datetime
model_config = {"from_attributes": True}
class RejectRequest(BaseModel):
"""Schema for rejecting a booking."""
reason: str | None = None
class BookingAdminCreate(BaseModel):
"""Schema for admin to create a booking directly (bypass approval)."""
space_id: int
user_id: int | None = None
start_datetime: datetime
end_datetime: datetime
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
class AdminCancelRequest(BaseModel):
"""Schema for admin cancelling a booking."""
cancellation_reason: str | None = None
class BookingUpdate(BaseModel):
"""Schema for updating a booking."""
title: str | None = None
description: str | None = None
start_datetime: datetime | None = None
end_datetime: datetime | None = None
class ConflictingBooking(BaseModel):
"""Schema for a conflicting booking in availability check."""
id: int
user_name: str
title: str
status: str
start_datetime: datetime
end_datetime: datetime
model_config = {"from_attributes": True}
class AvailabilityCheck(BaseModel):
"""Schema for availability check response."""
available: bool
conflicts: list[ConflictingBooking]
message: str
class BookingRecurringCreate(BaseModel):
"""Schema for creating recurring weekly bookings."""
space_id: int
start_time: str # Time only (e.g., "10:00")
duration_minutes: int
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
recurrence_days: list[int] = Field(..., min_length=1, max_length=7) # 0=Monday, 6=Sunday
start_date: date # First occurrence date
end_date: date # Last occurrence date
skip_conflicts: bool = True # Skip conflicted dates or stop
@field_validator('recurrence_days')
@classmethod
def validate_days(cls, v: list[int]) -> list[int]:
"""Validate recurrence days are valid weekdays."""
if not all(0 <= day <= 6 for day in v):
raise ValueError('Days must be 0-6 (0=Monday, 6=Sunday)')
return sorted(list(set(v))) # Remove duplicates and sort
@field_validator('end_date')
@classmethod
def validate_date_range(cls, v: date, info) -> date:
"""Validate end date is after start date and within 1 year."""
if 'start_date' in info.data and v < info.data['start_date']:
raise ValueError('end_date must be after start_date')
# Max 1 year
if 'start_date' in info.data and (v - info.data['start_date']).days > 365:
raise ValueError('Recurrence period cannot exceed 1 year')
return v
class RecurringBookingResult(BaseModel):
"""Schema for recurring booking creation result."""
total_requested: int
total_created: int
total_skipped: int
created_bookings: list[BookingResponse]
skipped_dates: list[dict] # [{"date": "2024-01-01", "reason": "..."}, ...]
class BookingReschedule(BaseModel):
"""Schema for rescheduling a booking (drag-and-drop)."""
start_datetime: datetime
end_datetime: datetime

View File

@@ -0,0 +1,28 @@
"""Booking template schemas for request/response."""
from pydantic import BaseModel, Field
class BookingTemplateCreate(BaseModel):
"""Schema for creating a new booking template."""
name: str = Field(..., min_length=1, max_length=200)
space_id: int | None = None
duration_minutes: int = Field(..., gt=0)
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
class BookingTemplateRead(BaseModel):
"""Schema for reading booking template data."""
id: int
user_id: int
name: str
space_id: int | None
space_name: str | None # From relationship
duration_minutes: int
title: str
description: str | None
usage_count: int
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,23 @@
"""Notification schemas."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class NotificationRead(BaseModel):
"""Schema for reading notifications."""
id: int
user_id: int
type: str
title: str
message: str
booking_id: Optional[int]
is_read: bool
created_at: datetime
class Config:
"""Pydantic config."""
from_attributes = True

View File

@@ -0,0 +1,64 @@
"""Report schemas."""
from datetime import date
from typing import Any
from pydantic import BaseModel
class DateRangeFilter(BaseModel):
"""Date range filter for reports."""
start_date: date | None = None
end_date: date | None = None
class SpaceUsageItem(BaseModel):
"""Space usage report item."""
space_id: int
space_name: str
total_bookings: int
approved_bookings: int
pending_bookings: int
rejected_bookings: int
canceled_bookings: int
total_hours: float
class SpaceUsageReport(BaseModel):
"""Space usage report."""
items: list[SpaceUsageItem]
total_bookings: int
date_range: dict[str, Any]
class TopUserItem(BaseModel):
"""Top user report item."""
user_id: int
user_name: str
user_email: str
total_bookings: int
approved_bookings: int
total_hours: float
class TopUsersReport(BaseModel):
"""Top users report."""
items: list[TopUserItem]
date_range: dict[str, Any]
class ApprovalRateReport(BaseModel):
"""Approval rate report."""
total_requests: int
approved: int
rejected: int
pending: int
canceled: int
approval_rate: float
rejection_rate: float
date_range: dict[str, Any]

View File

@@ -0,0 +1,30 @@
"""Settings schemas."""
from pydantic import BaseModel, Field
class SettingsBase(BaseModel):
"""Base settings schema."""
min_duration_minutes: int = Field(ge=15, le=480, default=30)
max_duration_minutes: int = Field(ge=30, le=1440, default=480)
working_hours_start: int = Field(ge=0, le=23, default=8)
working_hours_end: int = Field(ge=1, le=24, default=20)
max_bookings_per_day_per_user: int = Field(ge=1, le=20, default=3)
min_hours_before_cancel: int = Field(ge=0, le=72, default=2)
class SettingsUpdate(SettingsBase):
"""Settings update schema."""
pass
class SettingsResponse(SettingsBase):
"""Settings response schema."""
id: int
class Config:
"""Pydantic config."""
from_attributes = True

View File

@@ -0,0 +1,38 @@
"""Space schemas for request/response."""
from pydantic import BaseModel, Field
class SpaceBase(BaseModel):
"""Base space schema."""
name: str = Field(..., min_length=1)
type: str = Field(..., pattern="^(sala|birou)$")
capacity: int = Field(..., gt=0)
description: str | None = None
class SpaceCreate(SpaceBase):
"""Space creation schema."""
pass
class SpaceUpdate(SpaceBase):
"""Space update schema."""
pass
class SpaceStatusUpdate(BaseModel):
"""Space status update schema."""
is_active: bool
class SpaceResponse(SpaceBase):
"""Space response schema."""
id: int
is_active: bool
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,62 @@
"""User schemas for request/response."""
from pydantic import BaseModel, EmailStr
class UserBase(BaseModel):
"""Base user schema."""
email: EmailStr
full_name: str
organization: str | None = None
class UserCreate(UserBase):
"""User creation schema."""
password: str
role: str = "user"
class UserResponse(UserBase):
"""User response schema."""
id: int
role: str
is_active: bool
timezone: str = "UTC"
model_config = {"from_attributes": True}
class Token(BaseModel):
"""Token response schema."""
access_token: str
token_type: str
class UserUpdate(BaseModel):
"""User update schema."""
email: EmailStr | None = None
full_name: str | None = None
role: str | None = None
organization: str | None = None
class UserStatusUpdate(BaseModel):
"""User status update schema."""
is_active: bool
class ResetPasswordRequest(BaseModel):
"""Reset password request schema."""
new_password: str
class TokenData(BaseModel):
"""Token data schema."""
user_id: int | None = None