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:
41
backend/app/services/audit_service.py
Normal file
41
backend/app/services/audit_service.py
Normal 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
|
||||
112
backend/app/services/booking_service.py
Normal file
112
backend/app/services/booking_service.py
Normal 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
|
||||
153
backend/app/services/email_service.py
Normal file
153
backend/app/services/email_service.py
Normal 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)
|
||||
173
backend/app/services/google_calendar_service.py
Normal file
173
backend/app/services/google_calendar_service.py
Normal 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
|
||||
41
backend/app/services/notification_service.py
Normal file
41
backend/app/services/notification_service.py
Normal 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
|
||||
Reference in New Issue
Block a user