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:
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Schemas module
|
||||
22
backend/app/schemas/attachment.py
Normal file
22
backend/app/schemas/attachment.py
Normal 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
|
||||
21
backend/app/schemas/audit_log.py
Normal file
21
backend/app/schemas/audit_log.py
Normal 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)
|
||||
49
backend/app/schemas/auth.py
Normal file
49
backend/app/schemas/auth.py
Normal 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
|
||||
244
backend/app/schemas/booking.py
Normal file
244
backend/app/schemas/booking.py
Normal 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
|
||||
28
backend/app/schemas/booking_template.py
Normal file
28
backend/app/schemas/booking_template.py
Normal 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}
|
||||
23
backend/app/schemas/notification.py
Normal file
23
backend/app/schemas/notification.py
Normal 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
|
||||
64
backend/app/schemas/reports.py
Normal file
64
backend/app/schemas/reports.py
Normal 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]
|
||||
30
backend/app/schemas/settings.py
Normal file
30
backend/app/schemas/settings.py
Normal 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
|
||||
38
backend/app/schemas/space.py
Normal file
38
backend/app/schemas/space.py
Normal 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}
|
||||
62
backend/app/schemas/user.py
Normal file
62
backend/app/schemas/user.py
Normal 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
|
||||
Reference in New Issue
Block a user