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,192 @@
"""Attachments API endpoints."""
import os
import uuid
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session, joinedload
from app.core.deps import get_current_user, get_db
from app.models.attachment import Attachment
from app.models.booking import Booking
from app.models.user import User
from app.schemas.attachment import AttachmentRead
router = APIRouter()
UPLOAD_DIR = Path(__file__).parent.parent.parent / "uploads"
UPLOAD_DIR.mkdir(exist_ok=True)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
MAX_FILES_PER_BOOKING = 5
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".pptx", ".xlsx", ".xls", ".png", ".jpg", ".jpeg"}
@router.post("/bookings/{booking_id}/attachments", response_model=AttachmentRead, status_code=201)
async def upload_attachment(
booking_id: int,
file: Annotated[UploadFile, File()],
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> AttachmentRead:
"""Upload file attachment to booking."""
# Check booking exists and user is owner
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.user_id != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="Can only attach files to your own bookings")
# Check max files limit
existing_count = db.query(Attachment).filter(Attachment.booking_id == booking_id).count()
if existing_count >= MAX_FILES_PER_BOOKING:
raise HTTPException(
status_code=400, detail=f"Maximum {MAX_FILES_PER_BOOKING} files per booking"
)
# Validate file extension
if not file.filename:
raise HTTPException(status_code=400, detail="Filename is required")
file_ext = Path(file.filename).suffix.lower()
if file_ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400, detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
)
# Read file
content = await file.read()
file_size = len(content)
# Check file size
if file_size > MAX_FILE_SIZE:
raise HTTPException(
status_code=400, detail=f"File too large. Max size: {MAX_FILE_SIZE // (1024*1024)}MB"
)
# Generate unique filename
unique_filename = f"{uuid.uuid4()}{file_ext}"
filepath = UPLOAD_DIR / unique_filename
# Save file
with open(filepath, "wb") as f:
f.write(content)
# Create attachment record
attachment = Attachment(
booking_id=booking_id,
filename=file.filename,
stored_filename=unique_filename,
filepath=str(filepath),
size=file_size,
content_type=file.content_type or "application/octet-stream",
uploaded_by=current_user.id,
)
db.add(attachment)
db.commit()
db.refresh(attachment)
return AttachmentRead(
id=attachment.id,
booking_id=attachment.booking_id,
filename=attachment.filename,
size=attachment.size,
content_type=attachment.content_type,
uploaded_by=attachment.uploaded_by,
uploader_name=current_user.full_name,
created_at=attachment.created_at,
)
@router.get("/bookings/{booking_id}/attachments", response_model=list[AttachmentRead])
def list_attachments(
booking_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[AttachmentRead]:
"""List all attachments for a booking."""
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
attachments = (
db.query(Attachment)
.options(joinedload(Attachment.uploader))
.filter(Attachment.booking_id == booking_id)
.all()
)
return [
AttachmentRead(
id=a.id,
booking_id=a.booking_id,
filename=a.filename,
size=a.size,
content_type=a.content_type,
uploaded_by=a.uploaded_by,
uploader_name=a.uploader.full_name,
created_at=a.created_at,
)
for a in attachments
]
@router.get("/attachments/{attachment_id}/download")
def download_attachment(
attachment_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> FileResponse:
"""Download attachment file."""
attachment = (
db.query(Attachment)
.options(joinedload(Attachment.booking))
.filter(Attachment.id == attachment_id)
.first()
)
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
# Check if user can access (owner or admin)
if attachment.booking.user_id != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="Cannot access this attachment")
# Return file
return FileResponse(
path=attachment.filepath, filename=attachment.filename, media_type=attachment.content_type
)
@router.delete("/attachments/{attachment_id}", status_code=204)
def delete_attachment(
attachment_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> None:
"""Delete attachment."""
attachment = (
db.query(Attachment)
.options(joinedload(Attachment.booking))
.filter(Attachment.id == attachment_id)
.first()
)
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
# Check permission
if attachment.uploaded_by != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="Can only delete your own attachments")
# Delete file from disk
if os.path.exists(attachment.filepath):
os.remove(attachment.filepath)
# Delete record
db.delete(attachment)
db.commit()