This commit is contained in:
Iliyan Angelov
2025-12-05 22:12:32 +02:00
parent 13c91f95f4
commit 7667eb5eda
53 changed files with 3065 additions and 9257 deletions

View File

@@ -0,0 +1,62 @@
"""add_enterprise_promotion_conditions
Revision ID: b1c4d7c154ec
Revises: 87e29a777cb3
Create Date: 2025-12-05 20:22:39.893584
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'b1c4d7c154ec'
down_revision = '87e29a777cb3'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add enterprise promotion condition columns
op.add_column('promotions', sa.Column('min_stay_days', sa.Integer(), nullable=True, comment='Minimum number of nights required for booking'))
op.add_column('promotions', sa.Column('max_stay_days', sa.Integer(), nullable=True, comment='Maximum number of nights allowed for booking'))
op.add_column('promotions', sa.Column('advance_booking_days', sa.Integer(), nullable=True, comment='Minimum days in advance the booking must be made'))
op.add_column('promotions', sa.Column('max_advance_booking_days', sa.Integer(), nullable=True, comment='Maximum days in advance the booking can be made'))
# Day of week restrictions (stored as JSON)
op.add_column('promotions', sa.Column('allowed_check_in_days', sa.JSON(), nullable=True, comment='Allowed check-in days of week (0-6, Mon-Sun)'))
op.add_column('promotions', sa.Column('allowed_check_out_days', sa.JSON(), nullable=True, comment='Allowed check-out days of week (0-6, Mon-Sun)'))
# Room type restrictions (stored as JSON arrays)
op.add_column('promotions', sa.Column('allowed_room_type_ids', sa.JSON(), nullable=True, comment='Allowed room type IDs (JSON array)'))
op.add_column('promotions', sa.Column('excluded_room_type_ids', sa.JSON(), nullable=True, comment='Excluded room type IDs (JSON array)'))
# Guest count restrictions
op.add_column('promotions', sa.Column('min_guests', sa.Integer(), nullable=True, comment='Minimum number of guests required'))
op.add_column('promotions', sa.Column('max_guests', sa.Integer(), nullable=True, comment='Maximum number of guests allowed'))
# Customer type restrictions
op.add_column('promotions', sa.Column('first_time_customer_only', sa.Boolean(), nullable=False, server_default='0', comment='Only for first-time customers'))
op.add_column('promotions', sa.Column('repeat_customer_only', sa.Boolean(), nullable=False, server_default='0', comment='Only for returning customers'))
# Blackout dates (stored as JSON array)
op.add_column('promotions', sa.Column('blackout_dates', sa.JSON(), nullable=True, comment='Blackout dates when promotion doesn\'t apply (JSON array of YYYY-MM-DD)'))
def downgrade() -> None:
# Remove all added columns
op.drop_column('promotions', 'blackout_dates')
op.drop_column('promotions', 'repeat_customer_only')
op.drop_column('promotions', 'first_time_customer_only')
op.drop_column('promotions', 'max_guests')
op.drop_column('promotions', 'min_guests')
op.drop_column('promotions', 'excluded_room_type_ids')
op.drop_column('promotions', 'allowed_room_type_ids')
op.drop_column('promotions', 'allowed_check_out_days')
op.drop_column('promotions', 'allowed_check_in_days')
op.drop_column('promotions', 'max_advance_booking_days')
op.drop_column('promotions', 'advance_booking_days')
op.drop_column('promotions', 'max_stay_days')
op.drop_column('promotions', 'min_stay_days')

View File

@@ -1,9 +0,0 @@
{
"filename": "backup_hotel_booking_dev_20251202_232932.sql",
"path": "backups/backup_hotel_booking_dev_20251202_232932.sql",
"size_bytes": 526399,
"size_mb": 0.5,
"created_at": "2025-12-02T23:29:32.937870",
"database": "hotel_booking_dev",
"status": "success"
}

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +0,0 @@
{
"filename": "backup_hotel_booking_dev_20251205_144357.sql",
"path": "backups/backup_hotel_booking_dev_20251205_144357.sql",
"size_bytes": 653233,
"size_mb": 0.62,
"created_at": "2025-12-05T14:43:58.328707",
"database": "hotel_booking_dev",
"status": "success"
}

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
import sys
import os
import json
from datetime import datetime, timedelta
# Add parent directory to path to import from src
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy.orm import Session
@@ -150,34 +151,96 @@ def seed_homepage_content(db: Session):
trust_badges_section_subtitle = 'Awards and certifications that validate our commitment to excellence'
trust_badges_enabled = True
# Promotions
# Promotions - Mix of valid and expired for testing
# Calculate dates relative to current date
today = datetime.now()
next_month = today + timedelta(days=30)
next_3_months = today + timedelta(days=90)
next_6_months = today + timedelta(days=180)
expired_1_month_ago = today - timedelta(days=30)
expired_3_months_ago = today - timedelta(days=90)
promotions = [
{
'title': 'Early Bird Special',
'description': 'Book 30 days in advance and save 20% on your stay. Perfect for planning ahead!',
'image': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=600',
'discount': '20% OFF',
'valid_until': next_3_months.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Book Now',
'code': 'EARLYBIRD20'
},
{
'title': 'Weekend Getaway',
'description': 'Perfect weekend escape with complimentary breakfast and spa access. Relax and unwind!',
'image': 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=600',
'discount': '30% OFF',
'valid_until': next_month.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'View Offer',
'code': 'WEEKEND30'
},
{
'title': 'Luxury Suite Package',
'description': 'Experience our premium suites with exclusive amenities, fine dining, and concierge service',
'image': 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=600',
'discount': 'Save $200',
'valid_until': next_6_months.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Explore Suites',
'code': 'LUXURY200'
},
{
'title': 'Honeymoon Special',
'description': 'Romantic getaway with champagne, flowers, special amenities, and complimentary room upgrade',
'image': 'https://images.unsplash.com/photo-1596394516093-501ba68a0ba6?w=600',
'discount': '25% OFF',
'valid_until': next_3_months.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Book Package',
'code': 'HONEYMOON25'
},
{
'title': 'Family Fun Package',
'description': 'Perfect for families! Includes family room, kids activities, and complimentary meals for children under 12',
'image': 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=600',
'discount': '15% OFF',
'valid_until': next_6_months.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Book Now',
'code': 'FAMILY15'
},
{
'title': 'Business Traveler',
'description': 'Extended stay discounts for business travelers. Includes high-speed WiFi, workspace, and airport transfer',
'image': 'https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=600',
'discount': '10% OFF',
'valid_until': next_3_months.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Learn More',
'code': 'BUSINESS10'
},
# Expired promotions for testing display logic
{
'title': 'Summer Special',
'description': 'Enjoy 25% off on all room bookings this summer. Limited time offer!',
'image': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=600',
'discount': '25% OFF',
'valid_until': '2024-08-31',
'valid_until': expired_3_months_ago.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Book Now'
'button_text': 'Book Now',
'code': 'SUMMER25'
},
{
'title': 'Weekend Getaway',
'description': 'Perfect weekend escape with complimentary breakfast and spa access',
'image': 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=600',
'discount': '30% OFF',
'valid_until': '2024-12-31',
'title': 'New Year Celebration',
'description': 'Ring in the new year with our special celebration package. Includes party access and premium amenities',
'image': 'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=600',
'discount': '35% OFF',
'valid_until': expired_1_month_ago.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Learn More'
},
{
'title': 'Honeymoon Package',
'description': 'Romantic getaway with champagne, flowers, and special amenities',
'image': 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=600',
'discount': 'Special Rate',
'valid_until': '2024-12-31',
'link': '/rooms',
'button_text': 'Book Package'
'button_text': 'View Offer',
'code': 'NEWYEAR35'
}
]
promotions_section_title = 'Special Offers'

View File

@@ -0,0 +1,157 @@
import sys
import os
from datetime import datetime, timedelta
# Add parent directory to path to import from src
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy.orm import Session
from src.shared.config.database import SessionLocal
from src.loyalty.models.promotion import Promotion, DiscountType
def seed_promotions(db: Session):
"""Seed promotions that match the homepage promotion codes"""
# Calculate dates relative to current UTC date
# Use UTC consistently to match validation logic
now = datetime.utcnow()
# Start date: Start of today (00:00:00) to ensure promotion is immediately active
start_date = datetime(now.year, now.month, now.day, 0, 0, 0)
# End dates: future dates at end of day (23:59:59)
next_month_date = now + timedelta(days=30)
next_month = datetime(next_month_date.year, next_month_date.month, next_month_date.day, 23, 59, 59)
next_3_months_date = now + timedelta(days=90)
next_3_months = datetime(next_3_months_date.year, next_3_months_date.month, next_3_months_date.day, 23, 59, 59)
next_6_months_date = now + timedelta(days=180)
next_6_months = datetime(next_6_months_date.year, next_6_months_date.month, next_6_months_date.day, 23, 59, 59)
promotions_data = [
{
'code': 'EARLYBIRD20',
'name': 'Early Bird Special',
'description': 'Book 30 days in advance and save 20% on your stay. Perfect for planning ahead!',
'discount_type': DiscountType.percentage,
'discount_value': 20.00,
'start_date': start_date,
'end_date': next_3_months,
'is_active': True,
'min_booking_amount': None,
'max_discount_amount': None,
'usage_limit': None,
},
{
'code': 'WEEKEND30',
'name': 'Weekend Getaway',
'description': 'Perfect weekend escape with complimentary breakfast and spa access. Relax and unwind!',
'discount_type': DiscountType.percentage,
'discount_value': 30.00,
'start_date': start_date,
'end_date': next_month,
'is_active': True,
'min_booking_amount': None,
'max_discount_amount': None,
'usage_limit': None,
},
{
'code': 'LUXURY200',
'name': 'Luxury Suite Package',
'description': 'Experience our premium suites with exclusive amenities, fine dining, and concierge service',
'discount_type': DiscountType.fixed_amount,
'discount_value': 200.00,
'start_date': start_date,
'end_date': next_6_months,
'is_active': True,
'min_booking_amount': None, # No minimum for now
'max_discount_amount': None,
'usage_limit': None,
},
{
'code': 'HONEYMOON25',
'name': 'Honeymoon Special',
'description': 'Romantic getaway with champagne, flowers, special amenities, and complimentary room upgrade',
'discount_type': DiscountType.percentage,
'discount_value': 25.00,
'start_date': start_date,
'end_date': next_3_months,
'is_active': True,
'min_booking_amount': None,
'max_discount_amount': None,
'usage_limit': None,
},
{
'code': 'FAMILY15',
'name': 'Family Fun Package',
'description': 'Perfect for families! Includes family room, kids activities, and complimentary meals for children under 12',
'discount_type': DiscountType.percentage,
'discount_value': 15.00,
'start_date': start_date,
'end_date': next_6_months,
'is_active': True,
'min_booking_amount': None,
'max_discount_amount': None,
'usage_limit': None,
},
{
'code': 'BUSINESS10',
'name': 'Business Traveler',
'description': 'Extended stay discounts for business travelers. Includes high-speed WiFi, workspace, and airport transfer',
'discount_type': DiscountType.percentage,
'discount_value': 10.00,
'start_date': start_date,
'end_date': next_3_months,
'is_active': True,
'min_booking_amount': None,
'max_discount_amount': None,
'usage_limit': None,
},
]
created_count = 0
updated_count = 0
for promo_data in promotions_data:
existing = db.query(Promotion).filter(Promotion.code == promo_data['code']).first()
if existing:
# Update existing promotion
for key, value in promo_data.items():
if key != 'code': # Don't update the code
setattr(existing, key, value)
existing.updated_at = datetime.utcnow()
updated_count += 1
print(f'✓ Updated promotion: {promo_data["code"]}')
else:
# Create new promotion
promotion = Promotion(**promo_data)
db.add(promotion)
created_count += 1
print(f'✓ Created promotion: {promo_data["code"]}')
db.commit()
print(f'\n✓ Promotions seeded: {created_count} created, {updated_count} updated')
def main():
db: Session = SessionLocal()
try:
print('=' * 80)
print('SEEDING PROMOTIONS')
print('=' * 80)
print()
seed_promotions(db)
print('\n' + '=' * 80)
print('✓ All promotions seeded successfully!')
print('=' * 80)
except Exception as e:
db.rollback()
print(f'\n✗ Error seeding promotions: {e}')
import traceback
traceback.print_exc()
raise
finally:
db.close()
if __name__ == '__main__':
main()

View File

@@ -12,6 +12,7 @@ from ..models.guest_request import GuestRequest, RequestType, RequestStatus, Req
from ...bookings.models.booking import Booking, BookingStatus
from ...rooms.models.room import Room
from pydantic import BaseModel
from ...shared.utils.sanitization import sanitize_text
logger = get_logger(__name__)
router = APIRouter(prefix='/guest-requests', tags=['guest-requests'])
@@ -163,18 +164,135 @@ async def create_guest_request(
if booking.room_id != request_data.room_id:
raise HTTPException(status_code=400, detail='Room ID does not match booking')
# Sanitize user input to prevent XSS
sanitized_title = sanitize_text(request_data.title)
sanitized_description = sanitize_text(request_data.description) if request_data.description else None
sanitized_guest_notes = sanitize_text(request_data.guest_notes) if request_data.guest_notes else None
guest_request = GuestRequest(
booking_id=request_data.booking_id,
room_id=request_data.room_id,
user_id=current_user.id,
request_type=RequestType(request_data.request_type),
priority=RequestPriority(request_data.priority),
title=request_data.title,
description=request_data.description,
guest_notes=request_data.guest_notes,
title=sanitized_title,
description=sanitized_description,
guest_notes=sanitized_guest_notes,
)
db.add(guest_request)
db.flush() # Flush to get the ID for task creation
# Auto-create housekeeping task for request types that require housekeeping
request_type = RequestType(request_data.request_type)
task_types_requiring_housekeeping = {
RequestType.extra_towels,
RequestType.extra_pillows,
RequestType.room_cleaning,
RequestType.turndown_service,
RequestType.amenities,
}
if request_type in task_types_requiring_housekeeping:
try:
from ...hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
from ...rooms.models.room import Room
# Determine housekeeping task type based on request type
task_type_map = {
RequestType.room_cleaning: HousekeepingType.stayover,
RequestType.turndown_service: HousekeepingType.turndown,
RequestType.extra_towels: HousekeepingType.stayover,
RequestType.extra_pillows: HousekeepingType.stayover,
RequestType.amenities: HousekeepingType.stayover,
}
housekeeping_task_type = task_type_map.get(request_type, HousekeepingType.stayover)
# Create default checklist based on request type
checklist_items = []
if request_type == RequestType.room_cleaning:
checklist_items = [
{'item': 'Room cleaned', 'completed': False, 'notes': ''},
{'item': 'Bathroom cleaned', 'completed': False, 'notes': ''},
{'item': 'Trash emptied', 'completed': False, 'notes': ''},
{'item': 'Beds made', 'completed': False, 'notes': ''},
]
elif request_type == RequestType.turndown_service:
checklist_items = [
{'item': 'Beds turned down', 'completed': False, 'notes': ''},
{'item': 'Curtains closed', 'completed': False, 'notes': ''},
{'item': 'Lights dimmed', 'completed': False, 'notes': ''},
{'item': 'Amenities refreshed', 'completed': False, 'notes': ''},
]
elif request_type in [RequestType.extra_towels, RequestType.extra_pillows, RequestType.amenities]:
item_name = 'Extra towels' if request_type == RequestType.extra_towels else \
'Extra pillows' if request_type == RequestType.extra_pillows else 'Amenities'
checklist_items = [
{'item': f'{item_name} delivered', 'completed': False, 'notes': request_data.description or ''},
]
# Check if a similar task already exists for this room
existing_task = db.query(HousekeepingTask).filter(
and_(
HousekeepingTask.room_id == request_data.room_id,
HousekeepingTask.task_type == housekeeping_task_type,
HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress]),
HousekeepingTask.booking_id == request_data.booking_id
)
).first()
if not existing_task:
# Create housekeeping task
housekeeping_task = HousekeepingTask(
room_id=request_data.room_id,
booking_id=request_data.booking_id,
task_type=housekeeping_task_type,
status=HousekeepingStatus.pending,
scheduled_time=datetime.utcnow(), # Schedule immediately for guest requests
created_by=None, # Created by system/guest request
checklist_items=checklist_items,
notes=f'Auto-created from guest request: {request_data.title}. Guest notes: {request_data.guest_notes or "None"}',
estimated_duration_minutes=15 if request_type in [RequestType.extra_towels, RequestType.extra_pillows, RequestType.amenities] else 30
)
db.add(housekeeping_task)
db.flush()
# Link guest request to housekeeping task via notes
guest_request.staff_notes = f'Auto-created housekeeping task #{housekeeping_task.id}'
# Send notification to housekeeping users
try:
from ...notifications.routes.notification_routes import notification_manager
room = db.query(Room).filter(Room.id == request_data.room_id).first()
task_data_notification = {
'id': housekeeping_task.id,
'room_id': housekeeping_task.room_id,
'room_number': room.room_number if room else None,
'task_type': housekeeping_task.task_type.value,
'status': housekeeping_task.status.value,
'scheduled_time': housekeeping_task.scheduled_time.isoformat() if housekeeping_task.scheduled_time else None,
'guest_request_id': guest_request.id,
'guest_request_title': request_data.title,
'created_at': housekeeping_task.created_at.isoformat() if housekeeping_task.created_at else None
}
notification_data = {
'type': 'housekeeping_task_available',
'data': task_data_notification
}
# Send notification to all housekeeping users
await notification_manager.send_to_role('housekeeping', notification_data)
except Exception as e:
logger.error(f'Error sending housekeeping notification for guest request: {str(e)}', exc_info=True)
logger.info(f'Auto-created housekeeping task {housekeeping_task.id} for guest request {guest_request.id} (type: {request_type.value})')
except Exception as e:
# Don't fail guest request creation if task creation fails
logger.error(f'Error auto-creating housekeeping task for guest request: {str(e)}', exc_info=True)
db.commit()
db.refresh(guest_request)
@@ -378,7 +496,8 @@ async def fulfill_request(
request.fulfilled_at = datetime.utcnow()
if staff_notes:
request.staff_notes = (request.staff_notes or '') + f'\n{staff_notes}' if request.staff_notes else staff_notes
sanitized_notes = sanitize_text(staff_notes)
request.staff_notes = (request.staff_notes or '') + f'\n{sanitized_notes}' if request.staff_notes else sanitized_notes
if request.started_at:
delta = datetime.utcnow() - request.started_at

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
@@ -18,6 +18,30 @@ class Promotion(Base):
discount_value = Column(Numeric(10, 2), nullable=False)
min_booking_amount = Column(Numeric(10, 2), nullable=True)
max_discount_amount = Column(Numeric(10, 2), nullable=True)
min_stay_days = Column(Integer, nullable=True, comment="Minimum number of nights required for booking")
max_stay_days = Column(Integer, nullable=True, comment="Maximum number of nights allowed for booking")
advance_booking_days = Column(Integer, nullable=True, comment="Minimum days in advance the booking must be made")
max_advance_booking_days = Column(Integer, nullable=True, comment="Maximum days in advance the booking can be made")
# Day of week restrictions (0=Monday, 6=Sunday, stored as JSON array like [0,1,2] for Mon-Wed)
allowed_check_in_days = Column(JSON, nullable=True, comment="Allowed check-in days of week (0-6, Mon-Sun)")
allowed_check_out_days = Column(JSON, nullable=True, comment="Allowed check-out days of week (0-6, Mon-Sun)")
# Room type restrictions (stored as JSON array of room type IDs or names)
allowed_room_type_ids = Column(JSON, nullable=True, comment="Allowed room type IDs (JSON array)")
excluded_room_type_ids = Column(JSON, nullable=True, comment="Excluded room type IDs (JSON array)")
# Guest count restrictions
min_guests = Column(Integer, nullable=True, comment="Minimum number of guests required")
max_guests = Column(Integer, nullable=True, comment="Maximum number of guests allowed")
# Customer type restrictions
first_time_customer_only = Column(Boolean, nullable=False, default=False, comment="Only for first-time customers")
repeat_customer_only = Column(Boolean, nullable=False, default=False, comment="Only for returning customers")
# Blackout dates (stored as JSON array of date strings)
blackout_dates = Column(JSON, nullable=True, comment="Blackout dates when promotion doesn't apply (JSON array of YYYY-MM-DD)")
start_date = Column(DateTime, nullable=False)
end_date = Column(DateTime, nullable=False)
usage_limit = Column(Integer, nullable=True)

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import Optional
from datetime import datetime
from datetime import datetime, timedelta
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
@@ -17,6 +17,40 @@ from ..schemas.promotion import (
logger = get_logger(__name__)
router = APIRouter(prefix='/promotions', tags=['promotions'])
def serialize_promotion(promo: Promotion) -> dict:
"""Helper function to serialize promotion object with all fields"""
return {
'id': promo.id,
'code': promo.code,
'name': promo.name,
'description': promo.description,
'discount_type': promo.discount_type.value if isinstance(promo.discount_type, DiscountType) else promo.discount_type,
'discount_value': float(promo.discount_value) if promo.discount_value else 0.0,
'min_booking_amount': float(promo.min_booking_amount) if promo.min_booking_amount else None,
'max_discount_amount': float(promo.max_discount_amount) if promo.max_discount_amount else None,
'min_stay_days': promo.min_stay_days,
'max_stay_days': promo.max_stay_days,
'advance_booking_days': promo.advance_booking_days,
'max_advance_booking_days': promo.max_advance_booking_days,
'allowed_check_in_days': promo.allowed_check_in_days,
'allowed_check_out_days': promo.allowed_check_out_days,
'allowed_room_type_ids': promo.allowed_room_type_ids,
'excluded_room_type_ids': promo.excluded_room_type_ids,
'min_guests': promo.min_guests,
'max_guests': promo.max_guests,
'first_time_customer_only': promo.first_time_customer_only,
'repeat_customer_only': promo.repeat_customer_only,
'blackout_dates': promo.blackout_dates,
'start_date': promo.start_date.isoformat() if promo.start_date else None,
'end_date': promo.end_date.isoformat() if promo.end_date else None,
'usage_limit': promo.usage_limit,
'used_count': promo.used_count,
'is_active': promo.is_active,
'status': 'active' if promo.is_active else 'inactive',
'created_at': promo.created_at.isoformat() if promo.created_at else None,
'updated_at': promo.updated_at.isoformat() if promo.updated_at else None,
}
@router.get('/')
async def get_promotions(search: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), type: Optional[str]=Query(None), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), db: Session=Depends(get_db)):
try:
@@ -36,7 +70,7 @@ async def get_promotions(search: Optional[str]=Query(None), status_filter: Optio
promotions = query.order_by(Promotion.created_at.desc()).offset(offset).limit(limit).all()
result = []
for promo in promotions:
promo_dict = {'id': promo.id, 'code': promo.code, 'name': promo.name, 'description': promo.description, 'discount_type': promo.discount_type.value if isinstance(promo.discount_type, DiscountType) else promo.discount_type, 'discount_value': float(promo.discount_value) if promo.discount_value else 0.0, 'min_booking_amount': float(promo.min_booking_amount) if promo.min_booking_amount else None, 'max_discount_amount': float(promo.max_discount_amount) if promo.max_discount_amount else None, 'start_date': promo.start_date.isoformat() if promo.start_date else None, 'end_date': promo.end_date.isoformat() if promo.end_date else None, 'usage_limit': promo.usage_limit, 'used_count': promo.used_count, 'is_active': promo.is_active, 'created_at': promo.created_at.isoformat() if promo.created_at else None}
promo_dict = serialize_promotion(promo)
result.append(promo_dict)
return {'status': 'success', 'data': {'promotions': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
except Exception as e:
@@ -50,7 +84,7 @@ async def get_promotion_by_code(code: str, db: Session=Depends(get_db)):
promotion = db.query(Promotion).filter(Promotion.code == code).first()
if not promotion:
raise HTTPException(status_code=404, detail='Promotion not found')
promo_dict = {'id': promotion.id, 'code': promotion.code, 'name': promotion.name, 'description': promotion.description, 'discount_type': promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type, 'discount_value': float(promotion.discount_value) if promotion.discount_value else 0.0, 'min_booking_amount': float(promotion.min_booking_amount) if promotion.min_booking_amount else None, 'max_discount_amount': float(promotion.max_discount_amount) if promotion.max_discount_amount else None, 'start_date': promotion.start_date.isoformat() if promotion.start_date else None, 'end_date': promotion.end_date.isoformat() if promotion.end_date else None, 'usage_limit': promotion.usage_limit, 'used_count': promotion.used_count, 'is_active': promotion.is_active}
promo_dict = serialize_promotion(promotion)
return {'status': 'success', 'data': {'promotion': promo_dict}}
except HTTPException:
raise
@@ -70,17 +104,171 @@ async def validate_promotion(validation_data: ValidatePromotionRequest, db: Sess
if not promotion.is_active:
raise HTTPException(status_code=400, detail='Promotion is not active')
now = datetime.utcnow()
if promotion.start_date and now < promotion.start_date:
raise HTTPException(status_code=400, detail='Promotion is not valid at this time')
if promotion.end_date and now > promotion.end_date:
raise HTTPException(status_code=400, detail='Promotion is not valid at this time')
if promotion.start_date:
# Compare datetimes directly (both should be timezone-naive UTC)
if now < promotion.start_date:
logger.warning(f'Promotion {code} not started yet. Now: {now}, Start: {promotion.start_date}')
raise HTTPException(status_code=400, detail=f'Promotion is not valid yet. Valid from {promotion.start_date.strftime("%Y-%m-%d %H:%M:%S")} UTC')
if promotion.end_date:
# Compare datetimes directly (both should be timezone-naive UTC)
if now > promotion.end_date:
logger.warning(f'Promotion {code} expired. Now: {now}, End: {promotion.end_date}')
raise HTTPException(status_code=400, detail=f'Promotion has expired. Valid until {promotion.end_date.strftime("%Y-%m-%d %H:%M:%S")} UTC')
if promotion.usage_limit and promotion.used_count >= promotion.usage_limit:
raise HTTPException(status_code=400, detail='Promotion usage limit reached')
if promotion.min_booking_amount and booking_amount < float(promotion.min_booking_amount):
raise HTTPException(status_code=400, detail=f'Minimum booking amount is {promotion.min_booking_amount}')
# Check booking conditions if dates are provided
if validation_data.check_in_date and validation_data.check_out_date:
try:
check_in = datetime.strptime(validation_data.check_in_date, '%Y-%m-%d').date()
check_out = datetime.strptime(validation_data.check_out_date, '%Y-%m-%d').date()
today = datetime.utcnow().date()
# Check minimum stay days
if promotion.min_stay_days:
stay_days = (check_out - check_in).days
if stay_days < promotion.min_stay_days:
raise HTTPException(
status_code=400,
detail=f'This promotion requires a minimum stay of {promotion.min_stay_days} night{"s" if promotion.min_stay_days > 1 else ""}. Your booking is for {stay_days} night{"s" if stay_days != 1 else ""}.'
)
# Check maximum stay days
if promotion.max_stay_days:
stay_days = (check_out - check_in).days
if stay_days > promotion.max_stay_days:
raise HTTPException(
status_code=400,
detail=f'This promotion allows a maximum stay of {promotion.max_stay_days} night{"s" if promotion.max_stay_days > 1 else ""}. Your booking is for {stay_days} night{"s" if stay_days != 1 else ""}.'
)
# Check advance booking requirement
if promotion.advance_booking_days:
days_until_checkin = (check_in - today).days
if days_until_checkin < promotion.advance_booking_days:
raise HTTPException(
status_code=400,
detail=f'This promotion requires booking at least {promotion.advance_booking_days} day{"s" if promotion.advance_booking_days > 1 else ""} in advance. Check-in is in {days_until_checkin} day{"s" if days_until_checkin != 1 else ""}.'
)
# Check maximum advance booking
if promotion.max_advance_booking_days:
days_until_checkin = (check_in - today).days
if days_until_checkin > promotion.max_advance_booking_days:
raise HTTPException(
status_code=400,
detail=f'This promotion allows booking up to {promotion.max_advance_booking_days} day{"s" if promotion.max_advance_booking_days > 1 else ""} in advance. Check-in is in {days_until_checkin} day{"s" if days_until_checkin != 1 else ""}.'
)
# Check day of week restrictions for check-in
if promotion.allowed_check_in_days:
check_in_weekday = check_in.weekday() # 0=Monday, 6=Sunday
if check_in_weekday not in promotion.allowed_check_in_days:
days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
allowed_days = ', '.join([days[d] for d in promotion.allowed_check_in_days])
raise HTTPException(
status_code=400,
detail=f'This promotion is only valid for check-in on: {allowed_days}'
)
# Check day of week restrictions for check-out
if promotion.allowed_check_out_days:
check_out_weekday = check_out.weekday()
if check_out_weekday not in promotion.allowed_check_out_days:
days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
allowed_days = ', '.join([days[d] for d in promotion.allowed_check_out_days])
raise HTTPException(
status_code=400,
detail=f'This promotion is only valid for check-out on: {allowed_days}'
)
# Check blackout dates
if promotion.blackout_dates:
check_in_str = check_in.strftime('%Y-%m-%d')
check_out_str = check_out.strftime('%Y-%m-%d')
# Check if check-in or check-out falls on a blackout date
if check_in_str in promotion.blackout_dates:
raise HTTPException(
status_code=400,
detail=f'This promotion is not valid for check-in on {check_in_str} (blackout date)'
)
if check_out_str in promotion.blackout_dates:
raise HTTPException(
status_code=400,
detail=f'This promotion is not valid for check-out on {check_out_str} (blackout date)'
)
# Check if any date in the stay range is a blackout date
current_date = check_in
while current_date < check_out:
if current_date.strftime('%Y-%m-%d') in promotion.blackout_dates:
raise HTTPException(
status_code=400,
detail=f'This promotion is not valid during your stay period (includes blackout date: {current_date.strftime("%Y-%m-%d")})'
)
current_date += timedelta(days=1)
except ValueError as e:
logger.warning(f'Invalid date format in promotion validation: {e}')
# Continue validation even if date parsing fails (backward compatibility)
# Check room type restrictions
if validation_data.room_type_id is not None:
if promotion.allowed_room_type_ids and validation_data.room_type_id not in promotion.allowed_room_type_ids:
raise HTTPException(
status_code=400,
detail='This promotion is not valid for the selected room type'
)
if promotion.excluded_room_type_ids and validation_data.room_type_id in promotion.excluded_room_type_ids:
raise HTTPException(
status_code=400,
detail='This promotion is not valid for the selected room type'
)
# Check guest count restrictions
if validation_data.guest_count is not None:
if promotion.min_guests and validation_data.guest_count < promotion.min_guests:
raise HTTPException(
status_code=400,
detail=f'This promotion requires a minimum of {promotion.min_guests} guest{"s" if promotion.min_guests > 1 else ""}'
)
if promotion.max_guests and validation_data.guest_count > promotion.max_guests:
raise HTTPException(
status_code=400,
detail=f'This promotion allows a maximum of {promotion.max_guests} guest{"s" if promotion.max_guests > 1 else ""}'
)
# Check customer type restrictions
if promotion.first_time_customer_only:
if validation_data.is_first_time_customer is None:
raise HTTPException(
status_code=400,
detail='This promotion is only available to first-time customers. Please log in to verify your customer status.'
)
if not validation_data.is_first_time_customer:
raise HTTPException(
status_code=400,
detail='This promotion is only available to first-time customers'
)
if promotion.repeat_customer_only:
if validation_data.is_first_time_customer is None:
raise HTTPException(
status_code=400,
detail='This promotion is only available to returning customers. Please log in to verify your customer status.'
)
if validation_data.is_first_time_customer:
raise HTTPException(
status_code=400,
detail='This promotion is only available to returning customers'
)
discount_amount = promotion.calculate_discount(booking_amount)
final_amount = booking_amount - discount_amount
return {'success': True, 'status': 'success', 'data': {'promotion': {'id': promotion.id, 'code': promotion.code, 'name': promotion.name, 'description': promotion.description, 'discount_type': promotion.discount_type.value if hasattr(promotion.discount_type, 'value') else str(promotion.discount_type), 'discount_value': float(promotion.discount_value) if promotion.discount_value else 0, 'min_booking_amount': float(promotion.min_booking_amount) if promotion.min_booking_amount else None, 'max_discount_amount': float(promotion.max_discount_amount) if promotion.max_discount_amount else None, 'start_date': promotion.start_date.isoformat() if promotion.start_date else None, 'end_date': promotion.end_date.isoformat() if promotion.end_date else None, 'usage_limit': promotion.usage_limit, 'used_count': promotion.used_count, 'status': 'active' if promotion.is_active else 'inactive'}, 'discount': discount_amount, 'original_amount': booking_amount, 'discount_amount': discount_amount, 'final_amount': final_amount}, 'message': 'Promotion validated successfully'}
promo_dict = serialize_promotion(promotion)
return {'success': True, 'status': 'success', 'data': {'promotion': promo_dict, 'discount': discount_amount, 'original_amount': booking_amount, 'discount_amount': discount_amount, 'final_amount': final_amount}, 'message': 'Promotion validated successfully'}
except HTTPException:
raise
except Exception as e:
@@ -105,6 +293,19 @@ async def create_promotion(promotion_data: CreatePromotionRequest, current_user:
discount_value=discount_value,
min_booking_amount=promotion_data.min_booking_amount,
max_discount_amount=promotion_data.max_discount_amount,
min_stay_days=promotion_data.min_stay_days,
max_stay_days=promotion_data.max_stay_days,
advance_booking_days=promotion_data.advance_booking_days,
max_advance_booking_days=promotion_data.max_advance_booking_days,
allowed_check_in_days=promotion_data.allowed_check_in_days,
allowed_check_out_days=promotion_data.allowed_check_out_days,
allowed_room_type_ids=promotion_data.allowed_room_type_ids,
excluded_room_type_ids=promotion_data.excluded_room_type_ids,
min_guests=promotion_data.min_guests,
max_guests=promotion_data.max_guests,
first_time_customer_only=promotion_data.first_time_customer_only or False,
repeat_customer_only=promotion_data.repeat_customer_only or False,
blackout_dates=promotion_data.blackout_dates,
start_date=datetime.fromisoformat(promotion_data.start_date.replace('Z', '+00:00')) if promotion_data.start_date else None,
end_date=datetime.fromisoformat(promotion_data.end_date.replace('Z', '+00:00')) if promotion_data.end_date else None,
usage_limit=promotion_data.usage_limit,
@@ -151,6 +352,32 @@ async def update_promotion(id: int, promotion_data: UpdatePromotionRequest, curr
promotion.min_booking_amount = promotion_data.min_booking_amount
if promotion_data.max_discount_amount is not None:
promotion.max_discount_amount = promotion_data.max_discount_amount
if promotion_data.min_stay_days is not None:
promotion.min_stay_days = promotion_data.min_stay_days
if promotion_data.max_stay_days is not None:
promotion.max_stay_days = promotion_data.max_stay_days
if promotion_data.advance_booking_days is not None:
promotion.advance_booking_days = promotion_data.advance_booking_days
if promotion_data.max_advance_booking_days is not None:
promotion.max_advance_booking_days = promotion_data.max_advance_booking_days
if promotion_data.allowed_check_in_days is not None:
promotion.allowed_check_in_days = promotion_data.allowed_check_in_days
if promotion_data.allowed_check_out_days is not None:
promotion.allowed_check_out_days = promotion_data.allowed_check_out_days
if promotion_data.allowed_room_type_ids is not None:
promotion.allowed_room_type_ids = promotion_data.allowed_room_type_ids
if promotion_data.excluded_room_type_ids is not None:
promotion.excluded_room_type_ids = promotion_data.excluded_room_type_ids
if promotion_data.min_guests is not None:
promotion.min_guests = promotion_data.min_guests
if promotion_data.max_guests is not None:
promotion.max_guests = promotion_data.max_guests
if promotion_data.first_time_customer_only is not None:
promotion.first_time_customer_only = promotion_data.first_time_customer_only
if promotion_data.repeat_customer_only is not None:
promotion.repeat_customer_only = promotion_data.repeat_customer_only
if promotion_data.blackout_dates is not None:
promotion.blackout_dates = promotion_data.blackout_dates
if promotion_data.start_date is not None:
promotion.start_date = datetime.fromisoformat(promotion_data.start_date.replace('Z', '+00:00')) if promotion_data.start_date else None
if promotion_data.end_date is not None:

View File

@@ -2,7 +2,7 @@
Pydantic schemas for promotion-related requests and responses.
"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from typing import Optional, List
from datetime import datetime
@@ -11,6 +11,11 @@ class ValidatePromotionRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=50, description="Promotion code")
booking_value: Optional[float] = Field(None, ge=0, description="Booking value/amount")
booking_amount: Optional[float] = Field(None, ge=0, description="Booking amount (alias for booking_value)")
check_in_date: Optional[str] = Field(None, description="Check-in date (ISO format YYYY-MM-DD)")
check_out_date: Optional[str] = Field(None, description="Check-out date (ISO format YYYY-MM-DD)")
room_type_id: Optional[int] = Field(None, description="Room type ID for the booking")
guest_count: Optional[int] = Field(None, ge=1, description="Number of guests")
is_first_time_customer: Optional[bool] = Field(None, description="Whether customer is booking for the first time")
@field_validator('code')
@classmethod
@@ -24,7 +29,9 @@ class ValidatePromotionRequest(BaseModel):
"json_schema_extra": {
"example": {
"code": "SUMMER2024",
"booking_value": 500.00
"booking_value": 500.00,
"check_in_date": "2024-06-15",
"check_out_date": "2024-06-22"
}
}
}
@@ -39,6 +46,30 @@ class CreatePromotionRequest(BaseModel):
discount_value: float = Field(..., gt=0, description="Discount value")
min_booking_amount: Optional[float] = Field(None, ge=0)
max_discount_amount: Optional[float] = Field(None, ge=0)
min_stay_days: Optional[int] = Field(None, ge=1, description="Minimum number of nights required for booking")
max_stay_days: Optional[int] = Field(None, ge=1, description="Maximum number of nights allowed for booking")
advance_booking_days: Optional[int] = Field(None, ge=0, description="Minimum days in advance the booking must be made")
max_advance_booking_days: Optional[int] = Field(None, ge=0, description="Maximum days in advance the booking can be made")
# Day of week restrictions (0=Monday, 6=Sunday)
allowed_check_in_days: Optional[List[int]] = Field(None, description="Allowed check-in days of week (0-6, Mon-Sun)")
allowed_check_out_days: Optional[List[int]] = Field(None, description="Allowed check-out days of week (0-6, Mon-Sun)")
# Room type restrictions
allowed_room_type_ids: Optional[List[int]] = Field(None, description="Allowed room type IDs")
excluded_room_type_ids: Optional[List[int]] = Field(None, description="Excluded room type IDs")
# Guest count restrictions
min_guests: Optional[int] = Field(None, ge=1, description="Minimum number of guests required")
max_guests: Optional[int] = Field(None, ge=1, description="Maximum number of guests allowed")
# Customer type restrictions
first_time_customer_only: Optional[bool] = Field(False, description="Only for first-time customers")
repeat_customer_only: Optional[bool] = Field(False, description="Only for returning customers")
# Blackout dates (array of date strings YYYY-MM-DD)
blackout_dates: Optional[List[str]] = Field(None, description="Blackout dates when promotion doesn't apply")
start_date: Optional[str] = Field(None, description="Start date (ISO format)")
end_date: Optional[str] = Field(None, description="End date (ISO format)")
usage_limit: Optional[int] = Field(None, ge=1)
@@ -97,6 +128,25 @@ class UpdatePromotionRequest(BaseModel):
discount_value: Optional[float] = Field(None, gt=0)
min_booking_amount: Optional[float] = Field(None, ge=0)
max_discount_amount: Optional[float] = Field(None, ge=0)
min_stay_days: Optional[int] = Field(None, ge=1)
max_stay_days: Optional[int] = Field(None, ge=1)
advance_booking_days: Optional[int] = Field(None, ge=0)
max_advance_booking_days: Optional[int] = Field(None, ge=0)
allowed_check_in_days: Optional[List[int]] = None
allowed_check_out_days: Optional[List[int]] = None
allowed_room_type_ids: Optional[List[int]] = None
excluded_room_type_ids: Optional[List[int]] = None
min_guests: Optional[int] = Field(None, ge=1)
max_guests: Optional[int] = Field(None, ge=1)
first_time_customer_only: Optional[bool] = None
repeat_customer_only: Optional[bool] = None
blackout_dates: Optional[List[str]] = None
start_date: Optional[str] = None
end_date: Optional[str] = None
usage_limit: Optional[int] = Field(None, ge=1)

View File

@@ -12,6 +12,7 @@ import json
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import authorize_roles
from ...security.middleware.step_up_auth import authorize_financial_access
from ...auth.models.user import User
from ..models.financial_audit_trail import FinancialAuditTrail, FinancialActionType
from ..services.financial_audit_service import financial_audit_service
@@ -33,7 +34,7 @@ async def get_financial_audit_trail(
end_date: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(100, ge=1, le=1000),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get financial audit trail records with filters. Requires step-up authentication."""
@@ -205,7 +206,7 @@ async def get_financial_audit_trail(
@router.get('/{record_id}')
async def get_audit_record(
record_id: int,
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get a specific audit trail record."""
@@ -259,7 +260,7 @@ async def export_audit_trail(
user_id: Optional[int] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Export financial audit trail to CSV or JSON. Requires step-up authentication."""
@@ -497,7 +498,7 @@ async def cleanup_old_audit_records(
@router.get('/retention/stats')
async def get_retention_stats(
retention_days: int = Query(2555, ge=365, le=3650),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get statistics about audit trail retention."""

View File

@@ -8,6 +8,7 @@ import io
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import authorize_roles
from ...security.middleware.step_up_auth import authorize_financial_access
from ...auth.models.user import User
from ..models.payment import Payment, PaymentStatus, PaymentMethod
from ..models.invoice import Invoice, InvoiceStatus
@@ -27,7 +28,7 @@ router = APIRouter(prefix='/financial', tags=['financial'])
async def get_profit_loss_report(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Generate Profit & Loss statement."""
@@ -240,7 +241,7 @@ async def get_profit_loss_report(
@router.get('/balance-sheet')
async def get_balance_sheet(
as_of_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Generate Balance Sheet statement."""
@@ -413,7 +414,7 @@ async def get_tax_report(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
format: Optional[str] = Query('json', regex='^(json|csv)$'),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Generate tax report with export capability. Requires step-up authentication for exports."""
@@ -545,7 +546,7 @@ async def get_payment_reconciliation(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
include_exceptions: bool = Query(True),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Generate payment reconciliation report with exception integration."""
@@ -643,7 +644,7 @@ async def get_payment_reconciliation(
async def get_refund_history(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get refund history and statistics."""

View File

@@ -8,6 +8,7 @@ from datetime import datetime
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import authorize_roles, get_current_user
from ...security.middleware.step_up_auth import authorize_financial_access
from ...auth.models.user import User
from ..services.gl_service import gl_service
from ..models.fiscal_period import FiscalPeriod, PeriodStatus
@@ -23,7 +24,7 @@ router = APIRouter(prefix='/financial/gl', tags=['general-ledger'])
async def get_trial_balance(
period_id: Optional[int] = Query(None),
as_of_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get trial balance for a period or as of a date."""
@@ -42,7 +43,7 @@ async def get_trial_balance(
@router.get('/periods')
async def get_fiscal_periods(
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get all fiscal periods."""
@@ -156,7 +157,7 @@ async def close_fiscal_period(
@router.get('/accounts')
async def get_chart_of_accounts(
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get chart of accounts."""
@@ -188,7 +189,7 @@ async def get_journal_entries(
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get journal entries with pagination."""

View File

@@ -5,6 +5,7 @@ from datetime import datetime
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
from ...security.middleware.step_up_auth import authorize_financial_access
from ...auth.models.user import User
from ..models.invoice import Invoice, InvoiceStatus
from ...bookings.models.booking import Booking
@@ -130,7 +131,7 @@ async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, c
raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}')
async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceRequest, current_user: User=Depends(authorize_roles('admin', 'accountant')), db: Session=Depends(get_db)):
async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceRequest, current_user: User=Depends(authorize_financial_access('admin', 'accountant')), db: Session=Depends(get_db)):
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
@@ -171,7 +172,7 @@ async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceR
raise HTTPException(status_code=500, detail=str(e))
@router.post('/{id}/mark-paid')
async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvoicePaidRequest, current_user: User=Depends(authorize_roles('admin', 'accountant')), db: Session=Depends(get_db)):
async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvoicePaidRequest, current_user: User=Depends(authorize_financial_access('admin', 'accountant')), db: Session=Depends(get_db)):
try:
request_id = get_request_id(request)
amount = payment_data.amount

View File

@@ -8,6 +8,7 @@ from datetime import datetime
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import authorize_roles, get_current_user
from ...security.middleware.step_up_auth import authorize_financial_access
from ...auth.models.user import User
from ..services.reconciliation_service import reconciliation_service
from ..models.reconciliation_exception import ExceptionStatus, ExceptionType
@@ -21,7 +22,7 @@ router = APIRouter(prefix='/financial/reconciliation', tags=['reconciliation'])
async def run_reconciliation(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Run reconciliation and detect exceptions."""
@@ -55,7 +56,7 @@ async def get_reconciliation_exceptions(
severity: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get reconciliation exceptions with filters."""
@@ -96,7 +97,7 @@ async def get_reconciliation_exceptions(
async def assign_exception(
exception_id: int,
assign_data: dict,
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Assign an exception to a user."""
@@ -130,7 +131,7 @@ async def assign_exception(
async def resolve_exception(
exception_id: int,
resolve_data: dict,
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Resolve an exception."""
@@ -165,7 +166,7 @@ async def resolve_exception(
async def add_exception_comment(
exception_id: int,
comment_data: dict,
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Add a comment to an exception."""
@@ -198,7 +199,7 @@ async def add_exception_comment(
@router.get('/exceptions/stats')
async def get_exception_stats(
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get statistics about reconciliation exceptions."""

View File

@@ -9,6 +9,7 @@ import hashlib
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
from ...shared.utils.sanitization import sanitize_text
from ...auth.models.user import User
from ...auth.models.role import Role
from ..models.room import Room, RoomStatus
@@ -246,12 +247,17 @@ async def create_maintenance_record(
if maintenance_data.get('block_end'):
block_end = datetime.fromisoformat(maintenance_data['block_end'].replace('Z', '+00:00'))
# Sanitize user input
sanitized_title = sanitize_text(maintenance_data.get('title', 'Maintenance'))
sanitized_description = sanitize_text(maintenance_data.get('description')) if maintenance_data.get('description') else None
sanitized_notes = sanitize_text(maintenance_data.get('notes')) if maintenance_data.get('notes') else None
maintenance = RoomMaintenance(
room_id=maintenance_data['room_id'],
maintenance_type=MaintenanceType(maintenance_data.get('maintenance_type', 'preventive')),
status=MaintenanceStatus(maintenance_data.get('status', 'scheduled')),
title=maintenance_data.get('title', 'Maintenance'),
description=maintenance_data.get('description'),
title=sanitized_title,
description=sanitized_description,
scheduled_start=scheduled_start,
scheduled_end=scheduled_end,
assigned_to=maintenance_data.get('assigned_to'),
@@ -261,7 +267,7 @@ async def create_maintenance_record(
block_start=block_start,
block_end=block_end,
priority=maintenance_data.get('priority', 'medium'),
notes=maintenance_data.get('notes')
notes=sanitized_notes
)
# Update room status if blocking and maintenance is active
@@ -362,7 +368,7 @@ async def update_maintenance_record(
if 'actual_end' in maintenance_data:
maintenance.actual_end = datetime.fromisoformat(maintenance_data['actual_end'].replace('Z', '+00:00'))
if 'completion_notes' in maintenance_data:
maintenance.completion_notes = maintenance_data['completion_notes']
maintenance.completion_notes = sanitize_text(maintenance_data['completion_notes']) if maintenance_data['completion_notes'] else None
if 'actual_cost' in maintenance_data:
maintenance.actual_cost = maintenance_data['actual_cost']
@@ -604,6 +610,9 @@ async def create_housekeeping_task(
if is_housekeeping and not assigned_to:
assigned_to = current_user.id
# Sanitize user input
sanitized_notes = sanitize_text(task_data.get('notes')) if task_data.get('notes') else None
task = HousekeepingTask(
room_id=task_data['room_id'],
booking_id=task_data.get('booking_id'),
@@ -613,7 +622,7 @@ async def create_housekeeping_task(
assigned_to=assigned_to,
created_by=current_user.id,
checklist_items=task_data.get('checklist_items', []),
notes=task_data.get('notes'),
notes=sanitized_notes,
estimated_duration_minutes=task_data.get('estimated_duration_minutes')
)
@@ -774,16 +783,16 @@ async def update_housekeeping_task(
if 'checklist_items' in task_data:
task.checklist_items = task_data['checklist_items']
if 'notes' in task_data:
task.notes = task_data['notes']
task.notes = sanitize_text(task_data['notes']) if task_data['notes'] else None
if 'issues_found' in task_data:
task.issues_found = task_data['issues_found']
task.issues_found = sanitize_text(task_data['issues_found']) if task_data['issues_found'] else None
if 'quality_score' in task_data:
task.quality_score = task_data['quality_score']
if 'inspected_by' in task_data:
task.inspected_by = task_data['inspected_by']
task.inspected_at = datetime.utcnow()
if 'inspection_notes' in task_data:
task.inspection_notes = task_data['inspection_notes']
task.inspection_notes = sanitize_text(task_data['inspection_notes']) if task_data['inspection_notes'] else None
if 'photos' in task_data:
task.photos = task_data['photos']
@@ -930,11 +939,11 @@ async def report_maintenance_issue_from_task(
if not room:
raise HTTPException(status_code=404, detail='Room not found')
# Create maintenance record
title = issue_data.get('title', f'Issue reported from Room {room.room_number}')
description = issue_data.get('description', '')
# Create maintenance record - sanitize user input
title = sanitize_text(issue_data.get('title', f'Issue reported from Room {room.room_number}'))
description = sanitize_text(issue_data.get('description', ''))
if task.notes:
description = f"Reported from housekeeping task.\n\nTask Notes: {task.notes}\n\nIssue Description: {description}".strip()
description = f"Reported from housekeeping task.\n\nTask Notes: {sanitize_text(task.notes)}\n\nIssue Description: {description}".strip()
else:
description = f"Reported from housekeeping task.\n\nIssue Description: {description}".strip()
@@ -949,7 +958,7 @@ async def report_maintenance_issue_from_task(
reported_by=current_user.id,
priority=issue_data.get('priority', 'high'),
blocks_room=issue_data.get('blocks_room', True),
notes=issue_data.get('notes', f'Reported from housekeeping task #{task_id}')
notes=sanitize_text(issue_data.get('notes', f'Reported from housekeeping task #{task_id}'))
)
# Update room status if blocking
@@ -1168,15 +1177,15 @@ async def update_room_inspection(
if 'overall_score' in inspection_data:
inspection.overall_score = inspection_data['overall_score']
if 'overall_notes' in inspection_data:
inspection.overall_notes = inspection_data['overall_notes']
inspection.overall_notes = sanitize_text(inspection_data['overall_notes']) if inspection_data['overall_notes'] else None
if 'issues_found' in inspection_data:
inspection.issues_found = inspection_data['issues_found']
inspection.issues_found = sanitize_text(inspection_data['issues_found']) if inspection_data['issues_found'] else None
if 'photos' in inspection_data:
inspection.photos = inspection_data['photos']
if 'requires_followup' in inspection_data:
inspection.requires_followup = inspection_data['requires_followup']
if 'followup_notes' in inspection_data:
inspection.followup_notes = inspection_data['followup_notes']
inspection.followup_notes = sanitize_text(inspection_data['followup_notes']) if inspection_data['followup_notes'] else None
if 'maintenance_request_id' in inspection_data:
inspection.maintenance_request_id = inspection_data['maintenance_request_id']

View File

@@ -124,6 +124,8 @@ def get_current_user(
)
# SECURITY: Check MFA for accountant/admin roles (warn but allow access for MFA setup)
# Note: MFA enforcement for financial endpoints is handled by route-level dependencies
# This check only logs warnings to allow users to access MFA setup pages
try:
from ...payments.services.accountant_security_service import accountant_security_service
from ...shared.utils.role_helpers import is_accountant, is_admin
@@ -135,7 +137,7 @@ def get_current_user(
is_enforced, reason = accountant_security_service.is_mfa_enforced(user, db)
if not is_enforced and reason:
# Log warning but allow access so user can set up MFA
# Individual routes can enforce MFA for sensitive operations
# Individual routes enforce MFA for sensitive operations using enforce_mfa_for_accountants()
logger.warning(
f'User {user.id} ({user.email}) accessed system without MFA enabled. '
f'MFA is required for {user.role.name if user.role else "unknown"} role. '

View File

@@ -0,0 +1,110 @@
"""
Permission-based dependencies for route authorization.
These provide granular permission checks instead of just role checks.
"""
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Callable
from ...shared.config.database import get_db
from ...security.middleware.auth import get_current_user
from ...auth.models.user import User
from ...shared.utils import role_helpers
from ...shared.config.logging_config import get_logger
logger = get_logger(__name__)
def require_permission(permission_check_func: Callable[[User, Session], bool], permission_name: str = None):
"""
Create a dependency that requires a specific permission.
Args:
permission_check_func: Function that takes (user, db) and returns bool
permission_name: Optional name for the permission (for error messages)
"""
def permission_checker(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
if not permission_check_func(current_user, db):
perm_name = permission_name or permission_check_func.__name__
logger.warning(
f'User {current_user.id} ({current_user.email}) attempted to access resource '
f'requiring permission: {perm_name}'
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Permission denied: {perm_name} required'
)
return current_user
return permission_checker
# Pre-built permission dependencies for common use cases
def require_invoice_creation():
"""Require permission to create invoices."""
return require_permission(
role_helpers.can_create_invoices,
'create_invoices'
)
def require_user_management():
"""Require permission to manage users (admin only)."""
return require_permission(
role_helpers.can_manage_users,
'manage_users'
)
def require_housekeeping_task_management():
"""Require permission to manage housekeeping tasks."""
return require_permission(
role_helpers.can_manage_housekeeping_tasks,
'manage_housekeeping_tasks'
)
def require_housekeeping_task_assignment():
"""Require permission to assign tasks to other users."""
return require_permission(
role_helpers.can_assign_housekeeping_tasks,
'assign_housekeeping_tasks'
)
def require_room_management():
"""Require permission to manage rooms."""
return require_permission(
role_helpers.can_manage_rooms,
'manage_rooms'
)
def require_booking_management():
"""Require permission to manage bookings."""
return require_permission(
role_helpers.can_manage_bookings,
'manage_bookings'
)
def require_booking_price_modification():
"""Require permission to modify booking prices (admin only)."""
return require_permission(
role_helpers.can_modify_booking_prices,
'modify_booking_prices'
)
def require_financial_report_access():
"""Require permission to view financial reports."""
def check_financial_access(user: User, db: Session) -> bool:
return role_helpers.user_has_permission(user, db, "financial.view_reports")
return require_permission(
check_financial_access,
'view_financial_reports'
)

View File

@@ -63,20 +63,30 @@ def require_step_up_auth(
def enforce_mfa_for_accountants():
"""
Dependency to enforce MFA for accountant/admin roles.
This blocks access if MFA is required but not enabled.
"""
async def mfa_enforcer(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
# Check if MFA is required
# Only enforce for accountant/admin roles
if not (is_accountant(current_user, db) or is_admin(current_user, db)):
return current_user # Regular users don't need MFA
# Check if MFA is required and enforced
is_enforced, reason = accountant_security_service.is_mfa_enforced(current_user, db)
if not is_enforced and reason:
# CRITICAL: Block access instead of just logging warning
logger.warning(
f'User {current_user.id} ({current_user.email}) attempted to access financial data without MFA. '
f'Access denied. Reason: {reason}'
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'error': 'mfa_required',
'message': reason,
'message': reason or 'Multi-factor authentication is required for this role. Please enable MFA to access financial data.',
'requires_mfa_setup': True
}
)
@@ -85,3 +95,62 @@ def enforce_mfa_for_accountants():
return mfa_enforcer
def authorize_financial_access(*allowed_roles: str):
"""
Combined dependency that enforces both role authorization AND MFA for financial endpoints.
Use this for all financial routes that require accountant/admin access.
"""
def financial_access_checker(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
from ...shared.utils.role_helpers import get_user_role_name
from ...auth.models.role import Role
# First check role authorization
if hasattr(current_user, 'role') and current_user.role is not None:
user_role_name = current_user.role.name
else:
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if not role:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='User role not found')
user_role_name = role.name
# Check if user has required role
if user_role_name not in allowed_roles:
# Normalise accountant_* → accountant for role checks
if (
user_role_name.startswith("accountant_")
and "accountant" in allowed_roles
):
pass # allowed
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You do not have permission to access this resource'
)
# Then enforce MFA for accountant/admin roles
if is_accountant(current_user, db) or is_admin(current_user, db):
is_enforced, reason = accountant_security_service.is_mfa_enforced(current_user, db)
if not is_enforced and reason:
# CRITICAL: Block access instead of just logging warning
logger.warning(
f'User {current_user.id} ({current_user.email}) attempted to access financial data without MFA. '
f'Access denied. Reason: {reason}'
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'error': 'mfa_required',
'message': reason or 'Multi-factor authentication is required for this role. Please enable MFA to access financial data.',
'requires_mfa_setup': True
}
)
return current_user
return financial_access_checker

View File

@@ -168,6 +168,20 @@ ROLE_PERMISSIONS = {
"financial.view_invoices",
"financial.manage_invoices",
"financial.view_payments",
"housekeeping.view_tasks",
"housekeeping.manage_tasks",
"housekeeping.view_rooms",
},
# Housekeeping: room cleaning and task management
"housekeeping": {
"housekeeping.view_tasks", # View assigned tasks and unassigned tasks
"housekeeping.manage_tasks", # Update, start, complete assigned tasks
"housekeeping.create_tasks", # Create tasks (self-assign)
"housekeeping.view_rooms", # View rooms they need to clean
"housekeeping.upload_photos", # Upload photos for tasks
"housekeeping.report_issues", # Report maintenance issues from tasks
"housekeeping.view_inspections", # View assigned inspections
"housekeeping.manage_inspections", # Complete assigned inspections
},
}
@@ -234,3 +248,44 @@ def can_manage_users(user: User, db: Session) -> bool:
return is_admin(user, db)
def can_manage_housekeeping_tasks(user: User, db: Session) -> bool:
"""Check if user can manage housekeeping tasks."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff", "housekeeping"}
def can_view_all_housekeeping_tasks(user: User, db: Session) -> bool:
"""Check if user can view all housekeeping tasks (not just assigned)."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff"}
def can_assign_housekeeping_tasks(user: User, db: Session) -> bool:
"""Check if user can assign tasks to other users."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff"}
def can_manage_rooms(user: User, db: Session) -> bool:
"""Check if user can manage rooms."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff"}
def can_manage_bookings(user: User, db: Session) -> bool:
"""Check if user can manage bookings."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff"}
def can_access_all_rooms(user: User, db: Session) -> bool:
"""Check if user can access all rooms."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff", "housekeeping"}
def can_modify_booking_prices(user: User, db: Session) -> bool:
"""Check if user can modify booking prices (admin only)."""
return is_admin(user, db)