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,229 @@
"""Booking template endpoints."""
from datetime import datetime, timedelta
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from app.core.deps import get_current_user, get_db
from app.models.booking import Booking
from app.models.booking_template import BookingTemplate
from app.models.user import User
from app.schemas.booking import BookingResponse
from app.schemas.booking_template import BookingTemplateCreate, BookingTemplateRead
from app.services.booking_service import validate_booking_rules
from app.services.email_service import send_booking_notification
from app.services.notification_service import create_notification
router = APIRouter(prefix="/booking-templates", tags=["booking-templates"])
@router.post("", response_model=BookingTemplateRead, status_code=status.HTTP_201_CREATED)
def create_template(
data: BookingTemplateCreate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> BookingTemplateRead:
"""
Create a new booking template.
- **name**: Template name (e.g., "Weekly Team Sync")
- **space_id**: Optional default space ID
- **duration_minutes**: Default duration in minutes
- **title**: Default booking title
- **description**: Optional default description
Returns the created template.
"""
template = BookingTemplate(
user_id=current_user.id, # type: ignore[arg-type]
name=data.name,
space_id=data.space_id,
duration_minutes=data.duration_minutes,
title=data.title,
description=data.description,
)
db.add(template)
db.commit()
db.refresh(template)
return BookingTemplateRead(
id=template.id, # type: ignore[arg-type]
user_id=template.user_id, # type: ignore[arg-type]
name=template.name, # type: ignore[arg-type]
space_id=template.space_id, # type: ignore[arg-type]
space_name=template.space.name if template.space else None, # type: ignore[union-attr]
duration_minutes=template.duration_minutes, # type: ignore[arg-type]
title=template.title, # type: ignore[arg-type]
description=template.description, # type: ignore[arg-type]
usage_count=template.usage_count, # type: ignore[arg-type]
)
@router.get("", response_model=list[BookingTemplateRead])
def list_templates(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[BookingTemplateRead]:
"""
List all booking templates for the current user.
Returns templates sorted by name.
"""
templates = (
db.query(BookingTemplate)
.options(joinedload(BookingTemplate.space))
.filter(BookingTemplate.user_id == current_user.id)
.order_by(BookingTemplate.name)
.all()
)
return [
BookingTemplateRead(
id=t.id, # type: ignore[arg-type]
user_id=t.user_id, # type: ignore[arg-type]
name=t.name, # type: ignore[arg-type]
space_id=t.space_id, # type: ignore[arg-type]
space_name=t.space.name if t.space else None, # type: ignore[union-attr]
duration_minutes=t.duration_minutes, # type: ignore[arg-type]
title=t.title, # type: ignore[arg-type]
description=t.description, # type: ignore[arg-type]
usage_count=t.usage_count, # type: ignore[arg-type]
)
for t in templates
]
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template(
id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> None:
"""
Delete a booking template.
Users can only delete their own templates.
"""
template = (
db.query(BookingTemplate)
.filter(
BookingTemplate.id == id,
BookingTemplate.user_id == current_user.id,
)
.first()
)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found",
)
db.delete(template)
db.commit()
return None
@router.post("/from-template/{template_id}", response_model=BookingResponse, status_code=status.HTTP_201_CREATED)
def create_booking_from_template(
template_id: int,
start_datetime: datetime,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> Booking:
"""
Create a booking from a template.
- **template_id**: ID of the template to use
- **start_datetime**: When the booking should start (ISO format)
The booking will use the template's space, title, description, and duration.
The end time is calculated automatically based on the template's duration.
Returns the created booking with status "pending" (requires admin approval).
"""
# Find template
template = (
db.query(BookingTemplate)
.filter(
BookingTemplate.id == template_id,
BookingTemplate.user_id == current_user.id,
)
.first()
)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found",
)
if not template.space_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template does not have a default space",
)
# Calculate end time
end_datetime = start_datetime + timedelta(minutes=template.duration_minutes) # type: ignore[arg-type]
# Validate booking rules
user_id = int(current_user.id) # type: ignore[arg-type]
errors = validate_booking_rules(
db=db,
space_id=int(template.space_id), # type: ignore[arg-type]
start_datetime=start_datetime,
end_datetime=end_datetime,
user_id=user_id,
)
if errors:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=errors[0], # Return first error
)
# Create booking
booking = Booking(
user_id=user_id,
space_id=template.space_id, # type: ignore[arg-type]
title=template.title, # type: ignore[arg-type]
description=template.description, # type: ignore[arg-type]
start_datetime=start_datetime,
end_datetime=end_datetime,
status="pending",
created_at=datetime.utcnow(),
)
db.add(booking)
# Increment usage count
template.usage_count = int(template.usage_count) + 1 # type: ignore[arg-type, assignment]
db.commit()
db.refresh(booking)
# Notify all admins about the new booking request
admins = db.query(User).filter(User.role == "admin").all()
for admin in admins:
create_notification(
db=db,
user_id=admin.id, # type: ignore[arg-type]
type="booking_created",
title="Noua Cerere de Rezervare",
message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {template.space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}", # type: ignore[union-attr, union-attr]
booking_id=booking.id,
)
# Send email notification to admin
background_tasks.add_task(
send_booking_notification,
booking,
"created",
admin.email,
current_user.full_name,
None,
)
return booking