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,41 @@
"""Audit service for logging admin actions."""
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from app.models.audit_log import AuditLog
def log_action(
db: Session,
action: str,
user_id: int,
target_type: str,
target_id: int,
details: Optional[Dict[str, Any]] = None
) -> AuditLog:
"""
Log an admin action to the audit log.
Args:
db: Database session
action: Action performed (e.g., 'booking_approved', 'space_created')
user_id: ID of the admin user who performed the action
target_type: Type of target entity ('booking', 'space', 'user', 'settings')
target_id: ID of the target entity
details: Optional dictionary with additional information
Returns:
Created AuditLog instance
"""
audit_log = AuditLog(
action=action,
user_id=user_id,
target_type=target_type,
target_id=target_id,
details=details or {}
)
db.add(audit_log)
db.commit()
db.refresh(audit_log)
return audit_log

View File

@@ -0,0 +1,112 @@
"""Booking validation service."""
from datetime import datetime
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.models.booking import Booking
from app.models.settings import Settings
def validate_booking_rules(
db: Session,
space_id: int,
user_id: int,
start_datetime: datetime,
end_datetime: datetime,
exclude_booking_id: int | None = None,
) -> list[str]:
"""
Validate booking against global settings rules.
Args:
db: Database session
space_id: ID of the space to book
user_id: ID of the user making the booking
start_datetime: Booking start time
end_datetime: Booking end time
exclude_booking_id: Optional booking ID to exclude from overlap check
(used when re-validating an existing booking)
Returns:
List of validation error messages (empty list = validation OK)
"""
errors = []
# Fetch settings (create default if not exists)
settings = db.query(Settings).filter(Settings.id == 1).first()
if not settings:
settings = Settings(
id=1,
min_duration_minutes=30,
max_duration_minutes=480,
working_hours_start=8,
working_hours_end=20,
max_bookings_per_day_per_user=3,
min_hours_before_cancel=2,
)
db.add(settings)
db.commit()
db.refresh(settings)
# a) Validate duration in range
duration_minutes = (end_datetime - start_datetime).total_seconds() / 60
if (
duration_minutes < settings.min_duration_minutes
or duration_minutes > settings.max_duration_minutes
):
errors.append(
f"Durata rezervării trebuie să fie între {settings.min_duration_minutes} "
f"și {settings.max_duration_minutes} minute"
)
# b) Validate working hours
if (
start_datetime.hour < settings.working_hours_start
or end_datetime.hour > settings.working_hours_end
):
errors.append(
f"Rezervările sunt permise doar între {settings.working_hours_start}:00 "
f"și {settings.working_hours_end}:00"
)
# c) Check for overlapping bookings
query = db.query(Booking).filter(
Booking.space_id == space_id,
Booking.status.in_(["approved", "pending"]),
and_(
Booking.start_datetime < end_datetime,
Booking.end_datetime > start_datetime,
),
)
# Exclude current booking if re-validating
if exclude_booking_id is not None:
query = query.filter(Booking.id != exclude_booking_id)
overlapping_bookings = query.first()
if overlapping_bookings:
errors.append("Spațiul este deja rezervat în acest interval")
# d) Check max bookings per day per user
booking_date = start_datetime.date()
start_of_day = datetime.combine(booking_date, datetime.min.time())
end_of_day = datetime.combine(booking_date, datetime.max.time())
user_bookings_count = (
db.query(Booking)
.filter(
Booking.user_id == user_id,
Booking.status.in_(["approved", "pending"]),
Booking.start_datetime >= start_of_day,
Booking.start_datetime <= end_of_day,
)
.count()
)
if user_bookings_count >= settings.max_bookings_per_day_per_user:
errors.append(
f"Ai atins limita de {settings.max_bookings_per_day_per_user} rezervări pe zi"
)
return errors

View File

@@ -0,0 +1,153 @@
"""Email service for sending booking notifications."""
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
import aiosmtplib
from app.core.config import settings
from app.models.booking import Booking
logger = logging.getLogger(__name__)
async def send_email(to: str, subject: str, body: str) -> bool:
"""Send email via SMTP. Returns True if successful."""
if not settings.smtp_enabled:
# Development mode: just log the email
logger.info(f"[EMAIL] To: {to}")
logger.info(f"[EMAIL] Subject: {subject}")
logger.info(f"[EMAIL] Body:\n{body}")
print(f"\n--- EMAIL ---")
print(f"To: {to}")
print(f"Subject: {subject}")
print(f"Body:\n{body}")
print(f"--- END EMAIL ---\n")
return True
try:
message = MIMEMultipart()
message["From"] = settings.smtp_from_address
message["To"] = to
message["Subject"] = subject
message.attach(MIMEText(body, "plain", "utf-8"))
await aiosmtplib.send(
message,
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_user if settings.smtp_user else None,
password=settings.smtp_password if settings.smtp_password else None,
)
return True
except Exception as e:
logger.error(f"Failed to send email to {to}: {e}")
return False
def generate_booking_email(
booking: Booking,
event_type: str,
user_email: str,
user_name: str,
extra_data: Optional[dict] = None,
) -> tuple[str, str]:
"""Generate email subject and body for booking events.
Returns: (subject, body)
"""
extra_data = extra_data or {}
space_name = booking.space.name if booking.space else "Unknown Space"
start_str = booking.start_datetime.strftime("%d.%m.%Y %H:%M")
end_str = booking.end_datetime.strftime("%H:%M")
if event_type == "created":
subject = "Cerere Nouă de Rezervare"
body = f"""Bună ziua,
O nouă cerere de rezervare necesită aprobarea dumneavoastră:
Utilizator: {user_name}
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Descriere: {booking.description or 'N/A'}
Vă rugăm să accesați panoul de administrare pentru a aproba sau respinge această cerere.
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "approved":
subject = "Rezervare Aprobată"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost aprobată:
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Vă așteptăm!
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "rejected":
reason = extra_data.get("rejection_reason", "Nu a fost specificat")
subject = "Rezervare Respinsă"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost respinsă:
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Motiv: {reason}
Vă rugăm să contactați administratorul pentru detalii.
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "canceled":
reason = extra_data.get("cancellation_reason", "Nu a fost specificat")
subject = "Rezervare Anulată"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost anulată de către administrator:
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Motiv: {reason}
Vă rugăm să contactați administratorul pentru detalii.
Cu stimă,
Sistemul de Rezervări
"""
else:
subject = "Notificare Rezervare"
body = f"Notificare despre rezervarea pentru {space_name} din {start_str}"
return subject, body
async def send_booking_notification(
booking: Booking,
event_type: str,
user_email: str,
user_name: str,
extra_data: Optional[dict] = None,
) -> bool:
"""Send booking notification email."""
subject, body = generate_booking_email(
booking, event_type, user_email, user_name, extra_data
)
return await send_email(user_email, subject, body)

View File

@@ -0,0 +1,173 @@
"""Google Calendar integration service."""
from datetime import datetime
from typing import Optional
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.booking import Booking
from app.models.google_calendar_token import GoogleCalendarToken
def get_google_calendar_service(db: Session, user_id: int):
"""
Get authenticated Google Calendar service for user.
Args:
db: Database session
user_id: User ID
Returns:
Google Calendar service object or None if not connected
"""
token_record = (
db.query(GoogleCalendarToken)
.filter(GoogleCalendarToken.user_id == user_id)
.first()
)
if not token_record:
return None
# Create credentials
creds = Credentials(
token=token_record.access_token,
refresh_token=token_record.refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
)
# Refresh if expired
if creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
# Update tokens in DB
token_record.access_token = creds.token # type: ignore[assignment]
if creds.expiry:
token_record.token_expiry = creds.expiry # type: ignore[assignment]
db.commit()
except Exception as e:
print(f"Failed to refresh Google token: {e}")
return None
# Build service
try:
service = build("calendar", "v3", credentials=creds)
return service
except Exception as e:
print(f"Failed to build Google Calendar service: {e}")
return None
def create_calendar_event(
db: Session, booking: Booking, user_id: int
) -> Optional[str]:
"""
Create Google Calendar event for booking.
Args:
db: Database session
booking: Booking object
user_id: User ID
Returns:
Google Calendar event ID or None if failed
"""
try:
service = get_google_calendar_service(db, user_id)
if not service:
return None
# Create event
event = {
"summary": f"{booking.space.name}: {booking.title}",
"description": booking.description or "",
"start": {
"dateTime": booking.start_datetime.isoformat(), # type: ignore[union-attr]
"timeZone": "UTC",
},
"end": {
"dateTime": booking.end_datetime.isoformat(), # type: ignore[union-attr]
"timeZone": "UTC",
},
}
created_event = service.events().insert(calendarId="primary", body=event).execute()
return created_event.get("id")
except Exception as e:
print(f"Failed to create Google Calendar event: {e}")
return None
def update_calendar_event(
db: Session, booking: Booking, user_id: int, event_id: str
) -> bool:
"""
Update Google Calendar event for booking.
Args:
db: Database session
booking: Booking object
user_id: User ID
event_id: Google Calendar event ID
Returns:
True if successful, False otherwise
"""
try:
service = get_google_calendar_service(db, user_id)
if not service:
return False
# Update event
event = {
"summary": f"{booking.space.name}: {booking.title}",
"description": booking.description or "",
"start": {
"dateTime": booking.start_datetime.isoformat(), # type: ignore[union-attr]
"timeZone": "UTC",
},
"end": {
"dateTime": booking.end_datetime.isoformat(), # type: ignore[union-attr]
"timeZone": "UTC",
},
}
service.events().update(
calendarId="primary", eventId=event_id, body=event
).execute()
return True
except Exception as e:
print(f"Failed to update Google Calendar event: {e}")
return False
def delete_calendar_event(db: Session, event_id: str, user_id: int) -> bool:
"""
Delete Google Calendar event.
Args:
db: Database session
event_id: Google Calendar event ID
user_id: User ID
Returns:
True if successful, False otherwise
"""
try:
service = get_google_calendar_service(db, user_id)
if not service:
return False
service.events().delete(calendarId="primary", eventId=event_id).execute()
return True
except Exception as e:
print(f"Failed to delete Google Calendar event: {e}")
return False

View File

@@ -0,0 +1,41 @@
"""Notification service."""
from typing import Optional
from sqlalchemy.orm import Session
from app.models.notification import Notification
def create_notification(
db: Session,
user_id: int,
type: str,
title: str,
message: str,
booking_id: Optional[int] = None,
) -> Notification:
"""
Create a new notification.
Args:
db: Database session
user_id: ID of the user to notify
type: Notification type (e.g., 'booking_created', 'booking_approved')
title: Notification title
message: Notification message
booking_id: Optional booking ID this notification relates to
Returns:
Created notification object
"""
notification = Notification(
user_id=user_id,
type=type,
title=title,
message=message,
booking_id=booking_id,
)
db.add(notification)
db.commit()
db.refresh(notification)
return notification