update
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
157
Backend/seeds_data/seed_promotions.py
Normal file
157
Backend/seeds_data/seed_promotions.py
Normal 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()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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:
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Binary file not shown.
@@ -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']
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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. '
|
||||
|
||||
110
Backend/src/security/middleware/permission_dependencies.py
Normal file
110
Backend/src/security/middleware/permission_dependencies.py
Normal 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'
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user