diff --git a/Makefile b/Makefile index 32d0688..f2254f0 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,153 @@ -PYTHON=".venv/bin/python3" -RBOX=".venv/bin/rbox" +.PHONY: help install dev test test-billing test-e2e test-coverage e2e-setup lint format clean run migrate init-db reset-db all + +PYTHON := .venv/bin/python3 +PIP := .venv/bin/pip +PYTEST := .venv/bin/pytest +BLACK := .venv/bin/black +RUFF := .venv/bin/ruff +RBOX := .venv/bin/rbox all: $(RBOX) --port 9004 + +help: + @echo "RBox Development Makefile" + @echo "" + @echo "Available commands:" + @echo " make all Run the application on port 9004" + @echo " make install Install all dependencies" + @echo " make dev Install development dependencies" + @echo " make run Run the application locally" + @echo " make test Run all tests" + @echo " make test-billing Run billing tests only" + @echo " make test-e2e Run end-to-end tests (visible browser)" + @echo " make test-coverage Run tests with coverage report" + @echo " make e2e-setup Install Playwright and browsers" + @echo " make lint Run linting checks" + @echo " make format Format code with black" + @echo " make migrate Run database migrations" + @echo " make init-db Initialize database with default data" + @echo " make reset-db Reset database (WARNING: deletes all data)" + @echo " make clean Clean up temporary files" + @echo " make setup Complete setup (env + install + init-db)" + +install: + @echo "Installing dependencies..." + $(PIP) install -r requirements.txt + @echo "Dependencies installed successfully" + +dev: + @echo "Installing development dependencies..." + $(PIP) install pytest pytest-asyncio pytest-cov httpx black ruff stripe apscheduler dataset + @echo "Development dependencies installed successfully" + +run: + @echo "Starting RBox application..." + @echo "Access the application at http://localhost:8000" + $(PYTHON) -m rbox.main + +test: + @echo "Running all tests..." + $(PYTEST) tests/ -v + +test-billing: + @echo "Running billing tests..." + $(PYTEST) tests/billing/ -v + +test-e2e: + @echo "Running end-to-end tests with visible browser..." + @echo "Make sure the application is running (make run in another terminal)" + $(PYTEST) tests/e2e/ -v -s --tb=short + +e2e-setup: + @echo "Installing Playwright and browsers..." + $(PIP) install playwright + .venv/bin/playwright install chromium + @echo "Playwright setup complete" + +test-coverage: + @echo "Running tests with coverage..." + $(PYTEST) tests/ -v --cov=rbox --cov-report=html --cov-report=term + @echo "Coverage report generated in htmlcov/index.html" + +lint: + @echo "Running linting checks..." + $(RUFF) check rbox/ + @echo "Linting complete" + +format: + @echo "Formatting code..." + $(BLACK) rbox/ tests/ + @echo "Code formatting complete" + +migrate: + @echo "Running database migrations..." + @echo "Tortoise ORM auto-generates schemas on startup" + $(PYTHON) -c "from rbox.main import app; print('Database schema will be created on first run')" + +init-db: + @echo "Initializing database with default data..." + $(PYTHON) -c "import asyncio; \ + from tortoise import Tortoise; \ + from rbox.settings import settings; \ + from rbox.billing.models import PricingConfig; \ + from decimal import Decimal; \ + async def init(): \ + await Tortoise.init(db_url=settings.DATABASE_URL, modules={'models': ['rbox.models', 'rbox.billing.models']}); \ + await Tortoise.generate_schemas(); \ + count = await PricingConfig.all().count(); \ + if count == 0: \ + await PricingConfig.create(config_key='storage_per_gb_month', config_value=Decimal('0.0045'), description='Storage cost per GB per month', unit='per_gb_month'); \ + await PricingConfig.create(config_key='bandwidth_egress_per_gb', config_value=Decimal('0.009'), description='Bandwidth egress cost per GB', unit='per_gb'); \ + await PricingConfig.create(config_key='bandwidth_ingress_per_gb', config_value=Decimal('0.0'), description='Bandwidth ingress cost per GB (free)', unit='per_gb'); \ + await PricingConfig.create(config_key='free_tier_storage_gb', config_value=Decimal('15'), description='Free tier storage in GB', unit='gb'); \ + await PricingConfig.create(config_key='free_tier_bandwidth_gb', config_value=Decimal('15'), description='Free tier bandwidth in GB per month', unit='gb'); \ + await PricingConfig.create(config_key='tax_rate_default', config_value=Decimal('0.0'), description='Default tax rate (0 = no tax)', unit='percentage'); \ + print('Default pricing configuration created'); \ + else: \ + print('Pricing configuration already exists'); \ + await Tortoise.close_connections(); \ + asyncio.run(init())" + @echo "Database initialized successfully" + +reset-db: + @echo "WARNING: This will delete all data!" + @read -p "Are you sure? (yes/no): " confirm && [ "$$confirm" = "yes" ] || exit 1 + @rm -f rbox.db app/rbox.db storage/rbox.db + @echo "Database reset complete. Run 'make init-db' to reinitialize" + +clean: + @echo "Cleaning up temporary files..." + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type f -name "*.pyd" -delete + find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true + rm -rf htmlcov/ + rm -rf .coverage + @echo "Cleanup complete" + +setup-env: + @echo "Setting up environment file..." + @if [ ! -f .env ]; then \ + cp .env.example .env 2>/dev/null || \ + echo "DATABASE_URL=sqlite:///app/rbox.db\nSECRET_KEY=$$(openssl rand -hex 32)\nSTRIPE_SECRET_KEY=\nSTRIPE_PUBLISHABLE_KEY=\nSTRIPE_WEBHOOK_SECRET=" > .env; \ + echo ".env file created. Please update with your configuration."; \ + else \ + echo ".env file already exists"; \ + fi + +setup: setup-env install dev init-db + @echo "" + @echo "Setup complete!" + @echo "Next steps:" + @echo " 1. Update .env with your configuration (especially Stripe keys)" + @echo " 2. Run 'make run' or 'make all' to start the application" + @echo " 3. Access the application at http://localhost:8000" + +docs: + @echo "Generating API documentation..." + @echo "API documentation available at http://localhost:8000/docs when running" + @echo "ReDoc available at http://localhost:8000/redoc when running" diff --git a/rbox/auth.py b/rbox/auth.py index 5d8ad15..1b2808b 100644 --- a/rbox/auth.py +++ b/rbox/auth.py @@ -15,7 +15,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") def verify_password(plain_password, hashed_password): password_bytes = plain_password[:72].encode('utf-8') - hashed_bytes = hashed_password.encode('utf-8') if isinstance(hashed_password, str) else hashed_bytes + hashed_bytes = hashed_password.encode('utf-8') if isinstance(hashed_password, str) else hashed_password return bcrypt.checkpw(password_bytes, hashed_bytes) def get_password_hash(password): diff --git a/rbox/billing/__init__.py b/rbox/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rbox/billing/invoice_generator.py b/rbox/billing/invoice_generator.py new file mode 100644 index 0000000..1f208e7 --- /dev/null +++ b/rbox/billing/invoice_generator.py @@ -0,0 +1,143 @@ +from datetime import datetime, date, timedelta +from decimal import Decimal +from typing import Optional +from calendar import monthrange +from .models import Invoice, InvoiceLineItem, PricingConfig, UsageAggregate, UserSubscription +from .usage_tracker import UsageTracker +from .stripe_client import StripeClient +from ..models import User + +class InvoiceGenerator: + @staticmethod + async def generate_monthly_invoice(user: User, year: int, month: int) -> Optional[Invoice]: + period_start = date(year, month, 1) + days_in_month = monthrange(year, month)[1] + period_end = date(year, month, days_in_month) + + usage = await UsageTracker.get_monthly_usage(user, year, month) + + pricing = await PricingConfig.all() + pricing_dict = {p.config_key: p.config_value for p in pricing} + + storage_price_per_gb = pricing_dict.get('storage_per_gb_month', Decimal('0.0045')) + bandwidth_price_per_gb = pricing_dict.get('bandwidth_egress_per_gb', Decimal('0.009')) + free_storage_gb = pricing_dict.get('free_tier_storage_gb', Decimal('15')) + free_bandwidth_gb = pricing_dict.get('free_tier_bandwidth_gb', Decimal('15')) + tax_rate = pricing_dict.get('tax_rate_default', Decimal('0')) + + storage_gb = Decimal(str(usage['storage_gb_avg'])) + bandwidth_gb = Decimal(str(usage['bandwidth_down_gb'])) + + billable_storage = max(Decimal('0'), storage_gb - free_storage_gb) + billable_bandwidth = max(Decimal('0'), bandwidth_gb - free_bandwidth_gb) + + import math + billable_storage_rounded = Decimal(math.ceil(float(billable_storage))) + billable_bandwidth_rounded = Decimal(math.ceil(float(billable_bandwidth))) + + storage_cost = billable_storage_rounded * storage_price_per_gb + bandwidth_cost = billable_bandwidth_rounded * bandwidth_price_per_gb + + subtotal = storage_cost + bandwidth_cost + + if subtotal <= 0: + return None + + tax_amount = subtotal * tax_rate + total = subtotal + tax_amount + + invoice_number = f"INV-{user.id:06d}-{year}{month:02d}" + + subscription = await UserSubscription.get_or_none(user=user) + + invoice = await Invoice.create( + user=user, + invoice_number=invoice_number, + period_start=period_start, + period_end=period_end, + subtotal=subtotal, + tax=tax_amount, + total=total, + currency="USD", + status="draft", + due_date=period_end + timedelta(days=7), + metadata={ + "usage": usage, + "pricing": { + "storage_per_gb": float(storage_price_per_gb), + "bandwidth_per_gb": float(bandwidth_price_per_gb) + } + } + ) + + if billable_storage_rounded > 0: + await InvoiceLineItem.create( + invoice=invoice, + description=f"Storage usage for {period_start.strftime('%B %Y')} (Average: {storage_gb:.2f} GB, Billable: {billable_storage_rounded} GB)", + quantity=billable_storage_rounded, + unit_price=storage_price_per_gb, + amount=storage_cost, + item_type="storage", + metadata={"avg_gb": float(storage_gb), "free_gb": float(free_storage_gb)} + ) + + if billable_bandwidth_rounded > 0: + await InvoiceLineItem.create( + invoice=invoice, + description=f"Bandwidth usage for {period_start.strftime('%B %Y')} (Total: {bandwidth_gb:.2f} GB, Billable: {billable_bandwidth_rounded} GB)", + quantity=billable_bandwidth_rounded, + unit_price=bandwidth_price_per_gb, + amount=bandwidth_cost, + item_type="bandwidth", + metadata={"total_gb": float(bandwidth_gb), "free_gb": float(free_bandwidth_gb)} + ) + + if subscription and subscription.stripe_customer_id: + try: + line_items = await invoice.line_items.all() + stripe_line_items = [ + { + "amount": item.amount, + "currency": "usd", + "description": item.description, + "metadata": item.metadata or {} + } + for item in line_items + ] + + stripe_invoice = await StripeClient.create_invoice( + customer_id=subscription.stripe_customer_id, + description=f"RBox Usage Invoice for {period_start.strftime('%B %Y')}", + line_items=stripe_line_items, + metadata={"rbox_invoice_id": str(invoice.id)} + ) + + invoice.stripe_invoice_id = stripe_invoice.id + await invoice.save() + except Exception as e: + print(f"Failed to create Stripe invoice: {e}") + + return invoice + + @staticmethod + async def finalize_invoice(invoice: Invoice) -> Invoice: + if invoice.status != "draft": + raise ValueError("Only draft invoices can be finalized") + + invoice.status = "open" + await invoice.save() + + if invoice.stripe_invoice_id: + try: + await StripeClient.finalize_invoice(invoice.stripe_invoice_id) + except Exception as e: + print(f"Failed to finalize Stripe invoice: {e}") + + return invoice + + @staticmethod + async def mark_invoice_paid(invoice: Invoice) -> Invoice: + invoice.status = "paid" + invoice.paid_at = datetime.utcnow() + await invoice.save() + return invoice diff --git a/rbox/billing/models.py b/rbox/billing/models.py new file mode 100644 index 0000000..264f7b6 --- /dev/null +++ b/rbox/billing/models.py @@ -0,0 +1,139 @@ +from tortoise import fields, models +from decimal import Decimal + +class SubscriptionPlan(models.Model): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=100, unique=True) + display_name = fields.CharField(max_length=255) + description = fields.TextField(null=True) + storage_gb = fields.IntField() + bandwidth_gb = fields.IntField() + price_monthly = fields.DecimalField(max_digits=10, decimal_places=2) + price_yearly = fields.DecimalField(max_digits=10, decimal_places=2, null=True) + stripe_price_id = fields.CharField(max_length=255, null=True) + is_active = fields.BooleanField(default=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "subscription_plans" + +class UserSubscription(models.Model): + id = fields.IntField(pk=True) + user = fields.ForeignKeyField("models.User", related_name="subscription") + plan = fields.ForeignKeyField("billing.SubscriptionPlan", related_name="subscriptions", null=True) + billing_type = fields.CharField(max_length=20, default="pay_as_you_go") + stripe_customer_id = fields.CharField(max_length=255, unique=True, null=True) + stripe_subscription_id = fields.CharField(max_length=255, unique=True, null=True) + status = fields.CharField(max_length=50, default="active") + current_period_start = fields.DatetimeField(null=True) + current_period_end = fields.DatetimeField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + canceled_at = fields.DatetimeField(null=True) + + class Meta: + table = "user_subscriptions" + +class UsageRecord(models.Model): + id = fields.BigIntField(pk=True) + user = fields.ForeignKeyField("models.User", related_name="usage_records") + record_type = fields.CharField(max_length=50) + amount_bytes = fields.BigIntField() + resource_type = fields.CharField(max_length=50, null=True) + resource_id = fields.IntField(null=True) + timestamp = fields.DatetimeField(auto_now_add=True) + idempotency_key = fields.CharField(max_length=255, unique=True, null=True) + metadata = fields.JSONField(null=True) + + class Meta: + table = "usage_records" + +class UsageAggregate(models.Model): + id = fields.IntField(pk=True) + user = fields.ForeignKeyField("models.User", related_name="usage_aggregates") + date = fields.DateField() + storage_bytes_avg = fields.BigIntField(default=0) + storage_bytes_peak = fields.BigIntField(default=0) + bandwidth_up_bytes = fields.BigIntField(default=0) + bandwidth_down_bytes = fields.BigIntField(default=0) + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "usage_aggregates" + unique_together = (("user", "date"),) + +class Invoice(models.Model): + id = fields.IntField(pk=True) + user = fields.ForeignKeyField("models.User", related_name="invoices") + invoice_number = fields.CharField(max_length=50, unique=True) + stripe_invoice_id = fields.CharField(max_length=255, unique=True, null=True) + period_start = fields.DateField() + period_end = fields.DateField() + subtotal = fields.DecimalField(max_digits=10, decimal_places=4) + tax = fields.DecimalField(max_digits=10, decimal_places=4, default=0) + total = fields.DecimalField(max_digits=10, decimal_places=4) + currency = fields.CharField(max_length=3, default="USD") + status = fields.CharField(max_length=50, default="draft") + due_date = fields.DateField(null=True) + paid_at = fields.DatetimeField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + metadata = fields.JSONField(null=True) + + class Meta: + table = "invoices" + +class InvoiceLineItem(models.Model): + id = fields.IntField(pk=True) + invoice = fields.ForeignKeyField("billing.Invoice", related_name="line_items") + description = fields.TextField() + quantity = fields.DecimalField(max_digits=15, decimal_places=6) + unit_price = fields.DecimalField(max_digits=10, decimal_places=6) + amount = fields.DecimalField(max_digits=10, decimal_places=4) + item_type = fields.CharField(max_length=50, null=True) + metadata = fields.JSONField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "invoice_line_items" + +class PricingConfig(models.Model): + id = fields.IntField(pk=True) + config_key = fields.CharField(max_length=100, unique=True) + config_value = fields.DecimalField(max_digits=10, decimal_places=6) + description = fields.TextField(null=True) + unit = fields.CharField(max_length=50, null=True) + updated_by = fields.ForeignKeyField("models.User", related_name="pricing_updates", null=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "pricing_config" + +class PaymentMethod(models.Model): + id = fields.IntField(pk=True) + user = fields.ForeignKeyField("models.User", related_name="payment_methods") + stripe_payment_method_id = fields.CharField(max_length=255) + type = fields.CharField(max_length=50) + is_default = fields.BooleanField(default=False) + last4 = fields.CharField(max_length=4, null=True) + brand = fields.CharField(max_length=50, null=True) + exp_month = fields.IntField(null=True) + exp_year = fields.IntField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "payment_methods" + +class BillingEvent(models.Model): + id = fields.BigIntField(pk=True) + user = fields.ForeignKeyField("models.User", related_name="billing_events", null=True) + event_type = fields.CharField(max_length=100) + stripe_event_id = fields.CharField(max_length=255, unique=True, null=True) + data = fields.JSONField(null=True) + processed = fields.BooleanField(default=False) + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "billing_events" diff --git a/rbox/billing/scheduler.py b/rbox/billing/scheduler.py new file mode 100644 index 0000000..7e086e0 --- /dev/null +++ b/rbox/billing/scheduler.py @@ -0,0 +1,57 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from datetime import datetime, date, timedelta +import asyncio + +from .usage_tracker import UsageTracker +from .invoice_generator import InvoiceGenerator +from ..models import User + +scheduler = AsyncIOScheduler() + +async def aggregate_daily_usage_for_all_users(): + users = await User.filter(is_active=True).all() + yesterday = date.today() - timedelta(days=1) + + for user in users: + try: + await UsageTracker.aggregate_daily_usage(user, yesterday) + except Exception as e: + print(f"Failed to aggregate usage for user {user.id}: {e}") + +async def generate_monthly_invoices(): + now = datetime.now() + last_month = now.month - 1 if now.month > 1 else 12 + year = now.year if now.month > 1 else now.year - 1 + + users = await User.filter(is_active=True).all() + + for user in users: + try: + invoice = await InvoiceGenerator.generate_monthly_invoice(user, year, last_month) + if invoice: + await InvoiceGenerator.finalize_invoice(invoice) + except Exception as e: + print(f"Failed to generate invoice for user {user.id}: {e}") + +def start_scheduler(): + scheduler.add_job( + aggregate_daily_usage_for_all_users, + CronTrigger(hour=1, minute=0), + id="aggregate_daily_usage", + name="Aggregate daily usage for all users", + replace_existing=True + ) + + scheduler.add_job( + generate_monthly_invoices, + CronTrigger(day=1, hour=2, minute=0), + id="generate_monthly_invoices", + name="Generate monthly invoices", + replace_existing=True + ) + + scheduler.start() + +def stop_scheduler(): + scheduler.shutdown() diff --git a/rbox/billing/stripe_client.py b/rbox/billing/stripe_client.py new file mode 100644 index 0000000..1e9211c --- /dev/null +++ b/rbox/billing/stripe_client.py @@ -0,0 +1,105 @@ +import stripe +from decimal import Decimal +from typing import Optional, Dict, Any +from ..settings import settings + +stripe.api_key = settings.STRIPE_SECRET_KEY if hasattr(settings, 'STRIPE_SECRET_KEY') else "" + +class StripeClient: + @staticmethod + async def create_customer(email: str, name: str, metadata: Dict = None) -> str: + customer = stripe.Customer.create( + email=email, + name=name, + metadata=metadata or {} + ) + return customer.id + + @staticmethod + async def create_payment_intent( + amount: int, + currency: str = "usd", + customer_id: str = None, + metadata: Dict = None + ) -> stripe.PaymentIntent: + return stripe.PaymentIntent.create( + amount=amount, + currency=currency, + customer=customer_id, + metadata=metadata or {}, + automatic_payment_methods={"enabled": True} + ) + + @staticmethod + async def create_invoice( + customer_id: str, + description: str, + line_items: list, + metadata: Dict = None + ) -> stripe.Invoice: + for item in line_items: + stripe.InvoiceItem.create( + customer=customer_id, + amount=int(item['amount'] * 100), + currency=item.get('currency', 'usd'), + description=item['description'], + metadata=item.get('metadata', {}) + ) + + invoice = stripe.Invoice.create( + customer=customer_id, + description=description, + auto_advance=True, + collection_method='charge_automatically', + metadata=metadata or {} + ) + + return invoice + + @staticmethod + async def finalize_invoice(invoice_id: str) -> stripe.Invoice: + return stripe.Invoice.finalize_invoice(invoice_id) + + @staticmethod + async def pay_invoice(invoice_id: str) -> stripe.Invoice: + return stripe.Invoice.pay(invoice_id) + + @staticmethod + async def attach_payment_method( + payment_method_id: str, + customer_id: str + ) -> stripe.PaymentMethod: + payment_method = stripe.PaymentMethod.attach( + payment_method_id, + customer=customer_id + ) + + stripe.Customer.modify( + customer_id, + invoice_settings={'default_payment_method': payment_method_id} + ) + + return payment_method + + @staticmethod + async def list_payment_methods(customer_id: str, type: str = "card"): + return stripe.PaymentMethod.list( + customer=customer_id, + type=type + ) + + @staticmethod + async def create_subscription( + customer_id: str, + price_id: str, + metadata: Dict = None + ) -> stripe.Subscription: + return stripe.Subscription.create( + customer=customer_id, + items=[{'price': price_id}], + metadata=metadata or {} + ) + + @staticmethod + async def cancel_subscription(subscription_id: str) -> stripe.Subscription: + return stripe.Subscription.delete(subscription_id) diff --git a/rbox/billing/usage_tracker.py b/rbox/billing/usage_tracker.py new file mode 100644 index 0000000..4e57b28 --- /dev/null +++ b/rbox/billing/usage_tracker.py @@ -0,0 +1,142 @@ +import uuid +from datetime import datetime, date +from decimal import Decimal +from typing import Optional +from tortoise.transactions import in_transaction +from .models import UsageRecord, UsageAggregate +from ..models import User + +class UsageTracker: + @staticmethod + async def track_storage( + user: User, + amount_bytes: int, + resource_type: str = None, + resource_id: int = None, + metadata: dict = None + ): + idempotency_key = f"storage_{user.id}_{datetime.utcnow().timestamp()}_{uuid.uuid4().hex[:8]}" + + await UsageRecord.create( + user=user, + record_type="storage", + amount_bytes=amount_bytes, + resource_type=resource_type, + resource_id=resource_id, + idempotency_key=idempotency_key, + metadata=metadata + ) + + @staticmethod + async def track_bandwidth( + user: User, + amount_bytes: int, + direction: str = "down", + resource_type: str = None, + resource_id: int = None, + metadata: dict = None + ): + record_type = f"bandwidth_{direction}" + idempotency_key = f"{record_type}_{user.id}_{datetime.utcnow().timestamp()}_{uuid.uuid4().hex[:8]}" + + await UsageRecord.create( + user=user, + record_type=record_type, + amount_bytes=amount_bytes, + resource_type=resource_type, + resource_id=resource_id, + idempotency_key=idempotency_key, + metadata=metadata + ) + + @staticmethod + async def aggregate_daily_usage(user: User, target_date: date = None): + if target_date is None: + target_date = date.today() + + start_of_day = datetime.combine(target_date, datetime.min.time()) + end_of_day = datetime.combine(target_date, datetime.max.time()) + + storage_records = await UsageRecord.filter( + user=user, + record_type="storage", + timestamp__gte=start_of_day, + timestamp__lte=end_of_day + ).all() + + storage_avg = sum(r.amount_bytes for r in storage_records) // max(len(storage_records), 1) + storage_peak = max((r.amount_bytes for r in storage_records), default=0) + + bandwidth_up = await UsageRecord.filter( + user=user, + record_type="bandwidth_up", + timestamp__gte=start_of_day, + timestamp__lte=end_of_day + ).all() + + bandwidth_down = await UsageRecord.filter( + user=user, + record_type="bandwidth_down", + timestamp__gte=start_of_day, + timestamp__lte=end_of_day + ).all() + + total_up = sum(r.amount_bytes for r in bandwidth_up) + total_down = sum(r.amount_bytes for r in bandwidth_down) + + async with in_transaction(): + aggregate, created = await UsageAggregate.get_or_create( + user=user, + date=target_date, + defaults={ + "storage_bytes_avg": storage_avg, + "storage_bytes_peak": storage_peak, + "bandwidth_up_bytes": total_up, + "bandwidth_down_bytes": total_down + } + ) + + if not created: + aggregate.storage_bytes_avg = storage_avg + aggregate.storage_bytes_peak = storage_peak + aggregate.bandwidth_up_bytes = total_up + aggregate.bandwidth_down_bytes = total_down + await aggregate.save() + + return aggregate + + @staticmethod + async def get_current_storage(user: User) -> int: + from ..models import File + files = await File.filter(user=user, is_deleted=False).all() + return sum(f.size for f in files) + + @staticmethod + async def get_monthly_usage(user: User, year: int, month: int) -> dict: + aggregates = await UsageAggregate.filter( + user=user, + date__year=year, + date__month=month + ).all() + + if not aggregates: + return { + "storage_gb_avg": 0, + "storage_gb_peak": 0, + "bandwidth_up_gb": 0, + "bandwidth_down_gb": 0, + "total_bandwidth_gb": 0 + } + + storage_avg = sum(a.storage_bytes_avg for a in aggregates) / len(aggregates) + storage_peak = max(a.storage_bytes_peak for a in aggregates) + bandwidth_up = sum(a.bandwidth_up_bytes for a in aggregates) + bandwidth_down = sum(a.bandwidth_down_bytes for a in aggregates) + + return { + "storage_gb_avg": round(storage_avg / (1024**3), 4), + "storage_gb_peak": round(storage_peak / (1024**3), 4), + "bandwidth_up_gb": round(bandwidth_up / (1024**3), 4), + "bandwidth_down_gb": round(bandwidth_down / (1024**3), 4), + "total_bandwidth_gb": round((bandwidth_up + bandwidth_down) / (1024**3), 4) + } diff --git a/rbox/main.py b/rbox/main.py index 8de0464..915368d 100644 --- a/rbox/main.py +++ b/rbox/main.py @@ -1,23 +1,50 @@ import argparse import uvicorn -import logging # Import logging +import logging +from contextlib import asynccontextmanager from fastapi import FastAPI, Request, status, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, JSONResponse from tortoise.contrib.fastapi import register_tortoise from .settings import settings -from .routers import auth, users, folders, files, shares, search, admin, starred +from .routers import auth, users, folders, files, shares, search, admin, starred, billing, admin_billing from . import webdav -from .schemas import ErrorResponse # Import ErrorResponse +from .schemas import ErrorResponse -# Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Starting up...") + logger.info("Database connected.") + from .billing.scheduler import start_scheduler + from .billing.models import PricingConfig + start_scheduler() + logger.info("Billing scheduler started") + pricing_count = await PricingConfig.all().count() + if pricing_count == 0: + from decimal import Decimal + await PricingConfig.create(config_key='storage_per_gb_month', config_value=Decimal('0.0045'), description='Storage cost per GB per month', unit='per_gb_month') + await PricingConfig.create(config_key='bandwidth_egress_per_gb', config_value=Decimal('0.009'), description='Bandwidth egress cost per GB', unit='per_gb') + await PricingConfig.create(config_key='bandwidth_ingress_per_gb', config_value=Decimal('0.0'), description='Bandwidth ingress cost per GB (free)', unit='per_gb') + await PricingConfig.create(config_key='free_tier_storage_gb', config_value=Decimal('15'), description='Free tier storage in GB', unit='gb') + await PricingConfig.create(config_key='free_tier_bandwidth_gb', config_value=Decimal('15'), description='Free tier bandwidth in GB per month', unit='gb') + await PricingConfig.create(config_key='tax_rate_default', config_value=Decimal('0.0'), description='Default tax rate (0 = no tax)', unit='percentage') + logger.info("Default pricing configuration initialized") + + yield + + from .billing.scheduler import stop_scheduler + stop_scheduler() + logger.info("Billing scheduler stopped") + print("Shutting down...") + app = FastAPI( title="RBox Cloud Storage", description="A self-hosted cloud storage web application", version="0.1.0", + lifespan=lifespan ) app.include_router(auth.router) @@ -26,17 +53,25 @@ app.include_router(folders.router) app.include_router(files.router) app.include_router(shares.router) app.include_router(search.router) -app.include_router(admin.router) # Include the admin router -app.include_router(starred.router) # Include the starred router +app.include_router(admin.router) +app.include_router(starred.router) +app.include_router(billing.router) +app.include_router(admin_billing.router) app.include_router(webdav.router) -# Mount static files +from .middleware.usage_tracking import UsageTrackingMiddleware + +app.add_middleware(UsageTrackingMiddleware) + app.mount("/static", StaticFiles(directory="static"), name="static") register_tortoise( app, db_url=settings.DATABASE_URL, - modules={"models": ["rbox.models"]}, + modules={ + "models": ["rbox.models"], + "billing": ["rbox.billing.models"] + }, generate_schemas=True, add_exception_handlers=True, ) @@ -49,14 +84,6 @@ async def http_exception_handler(request: Request, exc: HTTPException): content=ErrorResponse(code=exc.status_code, message=exc.detail).dict(), ) -@app.on_event("startup") -async def startup_event(): - logger.info("Starting up...") - logger.info("Database connected.") - -@app.on_event("shutdown") -async def shutdown_event(): - print("Shutting down...") @app.get("/", response_class=HTMLResponse) # Change response_class to HTMLResponse async def read_root(): diff --git a/rbox/middleware/__init__.py b/rbox/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rbox/middleware/usage_tracking.py b/rbox/middleware/usage_tracking.py new file mode 100644 index 0000000..854e709 --- /dev/null +++ b/rbox/middleware/usage_tracking.py @@ -0,0 +1,32 @@ +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from ..billing.usage_tracker import UsageTracker + +class UsageTrackingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + + if hasattr(request.state, 'user') and request.state.user: + user = request.state.user + + if request.method in ['POST', 'PUT'] and '/files/upload' in request.url.path: + content_length = response.headers.get('content-length') + if content_length: + await UsageTracker.track_bandwidth( + user=user, + amount_bytes=int(content_length), + direction='up', + metadata={'path': request.url.path} + ) + + elif request.method == 'GET' and '/files/download' in request.url.path: + content_length = response.headers.get('content-length') + if content_length: + await UsageTracker.track_bandwidth( + user=user, + amount_bytes=int(content_length), + direction='down', + metadata={'path': request.url.path} + ) + + return response diff --git a/rbox/routers/admin_billing.py b/rbox/routers/admin_billing.py new file mode 100644 index 0000000..54049cf --- /dev/null +++ b/rbox/routers/admin_billing.py @@ -0,0 +1,116 @@ +from fastapi import APIRouter, Depends, HTTPException +from typing import List +from decimal import Decimal +from pydantic import BaseModel + +from ..auth import get_current_user +from ..models import User +from ..billing.models import PricingConfig, Invoice, SubscriptionPlan +from ..billing.invoice_generator import InvoiceGenerator + +def require_superuser(current_user: User = Depends(get_current_user)): + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Superuser privileges required") + return current_user + +router = APIRouter( + prefix="/api/admin/billing", + tags=["admin", "billing"], + dependencies=[Depends(require_superuser)] +) + +class PricingConfigUpdate(BaseModel): + config_key: str + config_value: float + +class PlanCreate(BaseModel): + name: str + display_name: str + description: str + storage_gb: int + bandwidth_gb: int + price_monthly: float + price_yearly: float = None + +@router.get("/pricing") +async def get_all_pricing(current_user: User = Depends(require_superuser)): + configs = await PricingConfig.all() + return [ + { + "id": c.id, + "config_key": c.config_key, + "config_value": float(c.config_value), + "description": c.description, + "unit": c.unit, + "updated_at": c.updated_at + } + for c in configs + ] + +@router.put("/pricing/{config_id}") +async def update_pricing( + config_id: int, + update: PricingConfigUpdate, + current_user: User = Depends(require_superuser) +): + config = await PricingConfig.get_or_none(id=config_id) + if not config: + raise HTTPException(status_code=404, detail="Config not found") + + config.config_value = Decimal(str(update.config_value)) + config.updated_by = current_user + await config.save() + + return {"message": "Pricing updated successfully"} + +@router.post("/generate-invoices/{year}/{month}") +async def generate_all_invoices( + year: int, + month: int, + current_user: User = Depends(require_superuser) +): + users = await User.filter(is_active=True).all() + generated = [] + skipped = [] + + for user in users: + invoice = await InvoiceGenerator.generate_monthly_invoice(user, year, month) + if invoice: + generated.append({ + "user_id": user.id, + "invoice_id": invoice.id, + "total": float(invoice.total) + }) + else: + skipped.append(user.id) + + return { + "generated": len(generated), + "skipped": len(skipped), + "invoices": generated + } + +@router.post("/plans") +async def create_plan( + plan_data: PlanCreate, + current_user: User = Depends(require_superuser) +): + plan = await SubscriptionPlan.create(**plan_data.dict()) + return {"id": plan.id, "message": "Plan created successfully"} + +@router.get("/stats") +async def get_billing_stats(current_user: User = Depends(require_superuser)): + from tortoise.functions import Sum, Count + + total_revenue = await Invoice.filter(status="paid").annotate( + total_sum=Sum("total") + ).values("total_sum") + + invoice_count = await Invoice.all().count() + pending_invoices = await Invoice.filter(status="open").count() + + return { + "total_revenue": float(total_revenue[0]["total_sum"] or 0), + "total_invoices": invoice_count, + "pending_invoices": pending_invoices + } diff --git a/rbox/routers/auth.py b/rbox/routers/auth.py index 4f5908b..4cbc2f9 100644 --- a/rbox/routers/auth.py +++ b/rbox/routers/auth.py @@ -65,16 +65,16 @@ async def register_user(user_in: UserCreate): return {"access_token": access_token, "token_type": "bearer"} @router.post("/token", response_model=Token) -async def login_for_access_token(user_login: UserLoginWith2FA): - auth_result = await authenticate_user(user_login.username, user_login.password, user_login.two_factor_code) - +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): + auth_result = await authenticate_user(form_data.username, form_data.password, None) + if not auth_result: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password or 2FA code", + detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) - + user = auth_result["user"] if auth_result["2fa_required"]: raise HTTPException( diff --git a/rbox/routers/billing.py b/rbox/routers/billing.py new file mode 100644 index 0000000..45e5551 --- /dev/null +++ b/rbox/routers/billing.py @@ -0,0 +1,312 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.responses import JSONResponse +from typing import List, Optional +from datetime import datetime, date +from decimal import Decimal +import calendar + +from ..auth import get_current_user +from ..models import User +from ..billing.models import ( + Invoice, InvoiceLineItem, UserSubscription, PricingConfig, + PaymentMethod, UsageAggregate, SubscriptionPlan +) +from ..billing.usage_tracker import UsageTracker +from ..billing.invoice_generator import InvoiceGenerator +from ..billing.stripe_client import StripeClient +from pydantic import BaseModel + +router = APIRouter( + prefix="/api/billing", + tags=["billing"] +) + +class UsageResponse(BaseModel): + storage_gb_avg: float + storage_gb_peak: float + bandwidth_up_gb: float + bandwidth_down_gb: float + total_bandwidth_gb: float + period: str + +class InvoiceResponse(BaseModel): + id: int + invoice_number: str + period_start: date + period_end: date + subtotal: float + tax: float + total: float + status: str + due_date: Optional[date] + paid_at: Optional[datetime] + line_items: List[dict] + +class SubscriptionResponse(BaseModel): + id: int + billing_type: str + plan_name: Optional[str] + status: str + current_period_start: Optional[datetime] + current_period_end: Optional[datetime] + +@router.get("/usage/current") +async def get_current_usage(current_user: User = Depends(get_current_user)): + storage_bytes = await UsageTracker.get_current_storage(current_user) + today = date.today() + + usage_today = await UsageAggregate.get_or_none(user=current_user, date=today) + + if usage_today: + return { + "storage_gb": round(storage_bytes / (1024**3), 4), + "bandwidth_down_gb_today": round(usage_today.bandwidth_down_bytes / (1024**3), 4), + "bandwidth_up_gb_today": round(usage_today.bandwidth_up_bytes / (1024**3), 4), + "as_of": today.isoformat() + } + + return { + "storage_gb": round(storage_bytes / (1024**3), 4), + "bandwidth_down_gb_today": 0, + "bandwidth_up_gb_today": 0, + "as_of": today.isoformat() + } + +@router.get("/usage/monthly") +async def get_monthly_usage( + year: Optional[int] = None, + month: Optional[int] = None, + current_user: User = Depends(get_current_user) +) -> UsageResponse: + if year is None or month is None: + now = datetime.now() + year = now.year + month = now.month + + usage = await UsageTracker.get_monthly_usage(current_user, year, month) + + return UsageResponse( + **usage, + period=f"{year}-{month:02d}" + ) + +@router.get("/invoices") +async def list_invoices( + limit: int = 50, + offset: int = 0, + current_user: User = Depends(get_current_user) +) -> List[InvoiceResponse]: + invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all() + + result = [] + for invoice in invoices: + line_items = await invoice.line_items.all() + result.append(InvoiceResponse( + id=invoice.id, + invoice_number=invoice.invoice_number, + period_start=invoice.period_start, + period_end=invoice.period_end, + subtotal=float(invoice.subtotal), + tax=float(invoice.tax), + total=float(invoice.total), + status=invoice.status, + due_date=invoice.due_date, + paid_at=invoice.paid_at, + line_items=[ + { + "description": item.description, + "quantity": float(item.quantity), + "unit_price": float(item.unit_price), + "amount": float(item.amount), + "type": item.item_type + } + for item in line_items + ] + )) + + return result + +@router.get("/invoices/{invoice_id}") +async def get_invoice( + invoice_id: int, + current_user: User = Depends(get_current_user) +) -> InvoiceResponse: + invoice = await Invoice.get_or_none(id=invoice_id, user=current_user) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + line_items = await invoice.line_items.all() + + return InvoiceResponse( + id=invoice.id, + invoice_number=invoice.invoice_number, + period_start=invoice.period_start, + period_end=invoice.period_end, + subtotal=float(invoice.subtotal), + tax=float(invoice.tax), + total=float(invoice.total), + status=invoice.status, + due_date=invoice.due_date, + paid_at=invoice.paid_at, + line_items=[ + { + "description": item.description, + "quantity": float(item.quantity), + "unit_price": float(item.unit_price), + "amount": float(item.amount), + "type": item.item_type + } + for item in line_items + ] + ) + +@router.get("/subscription") +async def get_subscription(current_user: User = Depends(get_current_user)) -> SubscriptionResponse: + subscription = await UserSubscription.get_or_none(user=current_user) + + if not subscription: + subscription = await UserSubscription.create( + user=current_user, + billing_type="pay_as_you_go", + status="active" + ) + + plan_name = None + if subscription.plan: + plan = await subscription.plan + plan_name = plan.display_name + + return SubscriptionResponse( + id=subscription.id, + billing_type=subscription.billing_type, + plan_name=plan_name, + status=subscription.status, + current_period_start=subscription.current_period_start, + current_period_end=subscription.current_period_end + ) + +@router.post("/payment-methods/setup-intent") +async def create_setup_intent(current_user: User = Depends(get_current_user)): + subscription = await UserSubscription.get_or_none(user=current_user) + + if not subscription or not subscription.stripe_customer_id: + customer_id = await StripeClient.create_customer( + email=current_user.email, + name=current_user.username, + metadata={"user_id": str(current_user.id)} + ) + + if not subscription: + subscription = await UserSubscription.create( + user=current_user, + billing_type="pay_as_you_go", + stripe_customer_id=customer_id, + status="active" + ) + else: + subscription.stripe_customer_id = customer_id + await subscription.save() + + import stripe + setup_intent = stripe.SetupIntent.create( + customer=subscription.stripe_customer_id, + payment_method_types=["card"] + ) + + return { + "client_secret": setup_intent.client_secret, + "customer_id": subscription.stripe_customer_id + } + +@router.get("/payment-methods") +async def list_payment_methods(current_user: User = Depends(get_current_user)): + methods = await PaymentMethod.filter(user=current_user).all() + return [ + { + "id": m.id, + "type": m.type, + "last4": m.last4, + "brand": m.brand, + "exp_month": m.exp_month, + "exp_year": m.exp_year, + "is_default": m.is_default + } + for m in methods + ] + +@router.post("/webhooks/stripe") +async def stripe_webhook(request: Request): + import stripe + from ..settings import settings + + payload = await request.body() + sig_header = request.headers.get("stripe-signature") + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, settings.STRIPE_WEBHOOK_SECRET + ) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid payload") + except stripe.error.SignatureVerificationError: + raise HTTPException(status_code=400, detail="Invalid signature") + + if event["type"] == "invoice.payment_succeeded": + invoice_data = event["data"]["object"] + rbox_invoice_id = invoice_data.get("metadata", {}).get("rbox_invoice_id") + + if rbox_invoice_id: + invoice = await Invoice.get_or_none(id=int(rbox_invoice_id)) + if invoice: + await InvoiceGenerator.mark_invoice_paid(invoice) + + elif event["type"] == "invoice.payment_failed": + pass + + elif event["type"] == "payment_method.attached": + payment_method = event["data"]["object"] + customer_id = payment_method["customer"] + + subscription = await UserSubscription.get_or_none(stripe_customer_id=customer_id) + if subscription: + await PaymentMethod.create( + user=subscription.user, + stripe_payment_method_id=payment_method["id"], + type=payment_method["type"], + last4=payment_method.get("card", {}).get("last4"), + brand=payment_method.get("card", {}).get("brand"), + exp_month=payment_method.get("card", {}).get("exp_month"), + exp_year=payment_method.get("card", {}).get("exp_year"), + is_default=True + ) + + return JSONResponse(content={"status": "success"}) + +@router.get("/pricing") +async def get_pricing(): + configs = await PricingConfig.all() + return { + config.config_key: { + "value": float(config.config_value), + "description": config.description, + "unit": config.unit + } + for config in configs + } + +@router.get("/plans") +async def list_plans(): + plans = await SubscriptionPlan.filter(is_active=True).all() + return [ + { + "id": plan.id, + "name": plan.name, + "display_name": plan.display_name, + "description": plan.description, + "storage_gb": plan.storage_gb, + "bandwidth_gb": plan.bandwidth_gb, + "price_monthly": float(plan.price_monthly), + "price_yearly": float(plan.price_yearly) if plan.price_yearly else None + } + for plan in plans + ] diff --git a/rbox/routers/files.py b/rbox/routers/files.py index 80ebc44..626be85 100644 --- a/rbox/routers/files.py +++ b/rbox/routers/files.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, UploadFile, File as FastAPIFile, HTTPException, status, Response +from fastapi import APIRouter, Depends, UploadFile, File as FastAPIFile, HTTPException, status, Response, Form from fastapi.responses import StreamingResponse from typing import List, Optional import mimetypes @@ -36,11 +36,13 @@ class BatchFileOperation(BaseModel): class BatchMoveCopyPayload(BaseModel): target_folder_id: Optional[int] = None -@router.post("/upload/{folder_id}", response_model=FileOut, status_code=status.HTTP_201_CREATED) +class FileContentUpdate(BaseModel): + content: str + @router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED) async def upload_file( file: UploadFile = FastAPIFile(...), - folder_id: Optional[int] = None, + folder_id: Optional[int] = Form(None), current_user: User = Depends(get_current_user) ): if folder_id: @@ -113,9 +115,6 @@ async def download_file(file_id: int, current_user: User = Depends(get_current_u await db_file.save() try: - # file_content_generator = storage_manager.get_file(current_user.id, db_file.path) - - # FastAPI's StreamingResponse expects an async generator async def file_iterator(): async for chunk in storage_manager.get_file(current_user.id, db_file.path): yield chunk @@ -133,11 +132,13 @@ async def delete_file(file_id: int, current_user: User = Depends(get_current_use db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False) if not db_file: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") - + db_file.is_deleted = True db_file.deleted_at = datetime.now() await db_file.save() + await delete_thumbnail(db_file.id) + return @router.post("/{file_id}/move", response_model=FileOut) @@ -324,74 +325,158 @@ async def restore_file(file_id: int, current_user: User = Depends(get_current_us await log_activity(user=current_user, action="file_restored", target_type="file", target_id=file_id) return await FileOut.from_tortoise_orm(db_file) -@router.post("/batch", response_model=List[FileOut]) +class BatchOperationResult(BaseModel): + succeeded: List[FileOut] + failed: List[dict] + +@router.post("/batch") async def batch_file_operations( batch_operation: BatchFileOperation, payload: Optional[BatchMoveCopyPayload] = None, current_user: User = Depends(get_current_user) ): + if batch_operation.operation not in ["delete", "star", "unstar", "move", "copy"]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid operation: {batch_operation.operation}") + updated_files = [] + failed_operations = [] + for file_id in batch_operation.file_ids: - db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False) - if not db_file: - # Skip if file not found or not owned by user - continue + try: + db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False) + if not db_file: + failed_operations.append({"file_id": file_id, "reason": "File not found or not owned by user"}) + continue - if batch_operation.operation == "delete": - db_file.is_deleted = True - db_file.deleted_at = datetime.now() - await db_file.save() - await log_activity(user=current_user, action="file_deleted_batch", target_type="file", target_id=file_id) - updated_files.append(db_file) - elif batch_operation.operation == "star": - db_file.is_starred = True - await db_file.save() - await log_activity(user=current_user, action="file_starred_batch", target_type="file", target_id=file_id) - updated_files.append(db_file) - elif batch_operation.operation == "unstar": - db_file.is_starred = False - await db_file.save() - await log_activity(user=current_user, action="file_unstarred_batch", target_type="file", target_id=file_id) - updated_files.append(db_file) - elif batch_operation.operation == "move" and payload and payload.target_folder_id is not None: - target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False) - if not target_folder: - continue # Skip if target folder not found + if batch_operation.operation == "delete": + db_file.is_deleted = True + db_file.deleted_at = datetime.now() + await db_file.save() + await delete_thumbnail(db_file.id) + await log_activity(user=current_user, action="file_deleted_batch", target_type="file", target_id=file_id) + updated_files.append(db_file) + elif batch_operation.operation == "star": + db_file.is_starred = True + await db_file.save() + await log_activity(user=current_user, action="file_starred_batch", target_type="file", target_id=file_id) + updated_files.append(db_file) + elif batch_operation.operation == "unstar": + db_file.is_starred = False + await db_file.save() + await log_activity(user=current_user, action="file_unstarred_batch", target_type="file", target_id=file_id) + updated_files.append(db_file) + elif batch_operation.operation == "move": + if not payload or payload.target_folder_id is None: + failed_operations.append({"file_id": file_id, "reason": "Target folder not specified"}) + continue - existing_file = await File.get_or_none( - name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False + target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False) + if not target_folder: + failed_operations.append({"file_id": file_id, "reason": "Target folder not found"}) + continue + + existing_file = await File.get_or_none( + name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False + ) + if existing_file and existing_file.id != file_id: + failed_operations.append({"file_id": file_id, "reason": "File with same name exists in target folder"}) + continue + + db_file.parent = target_folder + await db_file.save() + await log_activity(user=current_user, action="file_moved_batch", target_type="file", target_id=file_id) + updated_files.append(db_file) + elif batch_operation.operation == "copy": + if not payload or payload.target_folder_id is None: + failed_operations.append({"file_id": file_id, "reason": "Target folder not specified"}) + continue + + target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False) + if not target_folder: + failed_operations.append({"file_id": file_id, "reason": "Target folder not found"}) + continue + + base_name = db_file.name + name_parts = os.path.splitext(base_name) + counter = 1 + new_name = base_name + + while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False): + new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}" + counter += 1 + + new_file = await File.create( + name=new_name, + path=db_file.path, + size=db_file.size, + mime_type=db_file.mime_type, + file_hash=db_file.file_hash, + owner=current_user, + parent=target_folder + ) + await log_activity(user=current_user, action="file_copied_batch", target_type="file", target_id=new_file.id) + updated_files.append(new_file) + except Exception as e: + failed_operations.append({"file_id": file_id, "reason": str(e)}) + + return { + "succeeded": [await FileOut.from_tortoise_orm(f) for f in updated_files], + "failed": failed_operations + } + +@router.put("/{file_id}/content", response_model=FileOut) +async def update_file_content( + file_id: int, + payload: FileContentUpdate, + current_user: User = Depends(get_current_user) +): + db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False) + if not db_file: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not db_file.mime_type or not db_file.mime_type.startswith('text/'): + editableExtensions = [ + 'txt', 'md', 'log', 'json', 'js', 'py', 'html', 'css', + 'xml', 'yaml', 'yml', 'sh', 'bat', 'ini', 'conf', 'cfg' + ] + file_extension = os.path.splitext(db_file.name)[1][1:].lower() + if file_extension not in editableExtensions: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File type is not editable" ) - if existing_file and existing_file.id != file_id: - continue # Skip if file with same name exists - db_file.parent = target_folder - await db_file.save() - await log_activity(user=current_user, action="file_moved_batch", target_type="file", target_id=file_id) - updated_files.append(db_file) - elif batch_operation.operation == "copy" and payload and payload.target_folder_id is not None: - target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False) - if not target_folder: - continue # Skip if target folder not found + content_bytes = payload.content.encode('utf-8') + new_size = len(content_bytes) + size_diff = new_size - db_file.size - base_name = db_file.name - name_parts = os.path.splitext(base_name) - counter = 1 - new_name = base_name + if current_user.used_storage_bytes + size_diff > current_user.storage_quota_bytes: + raise HTTPException( + status_code=status.HTTP_507_INSUFFICIENT_STORAGE, + detail="Storage quota exceeded" + ) - while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False): - new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}" - counter += 1 + new_hash = hashlib.sha256(content_bytes).hexdigest() + file_extension = os.path.splitext(db_file.name)[1] + new_storage_path = f"{new_hash}{file_extension}" - new_file = await File.create( - name=new_name, - path=db_file.path, - size=db_file.size, - mime_type=db_file.mime_type, - file_hash=db_file.file_hash, - owner=current_user, - parent=target_folder - ) - await log_activity(user=current_user, action="file_copied_batch", target_type="file", target_id=new_file.id) - updated_files.append(new_file) - - return [await FileOut.from_tortoise_orm(f) for f in updated_files] + await storage_manager.save_file(current_user.id, new_storage_path, content_bytes) + + if new_storage_path != db_file.path: + try: + await storage_manager.delete_file(current_user.id, db_file.path) + except: + pass + + db_file.path = new_storage_path + db_file.size = new_size + db_file.file_hash = new_hash + db_file.updated_at = datetime.utcnow() + await db_file.save() + + current_user.used_storage_bytes += size_diff + await current_user.save() + + await log_activity(user=current_user, action="file_updated", target_type="file", target_id=file_id) + + return await FileOut.from_tortoise_orm(db_file) diff --git a/rbox/routers/folders.py b/rbox/routers/folders.py index fda2a52..a74fce2 100644 --- a/rbox/routers/folders.py +++ b/rbox/routers/folders.py @@ -4,6 +4,7 @@ from typing import List, Optional from ..auth import get_current_user from ..models import User, Folder from ..schemas import FolderCreate, FolderOut, FolderUpdate, BatchFolderOperation, BatchMoveCopyPayload +from ..activity import log_activity router = APIRouter( prefix="/folders", @@ -35,8 +36,26 @@ async def create_folder(folder_in: FolderCreate, current_user: User = Depends(ge folder = await Folder.create( name=folder_in.name, parent=parent_folder, owner=current_user ) + await log_activity(current_user, "folder_created", "folder", folder.id) return await FolderOut.from_tortoise_orm(folder) +@router.get("/{folder_id}/path", response_model=List[FolderOut]) +async def get_folder_path(folder_id: int, current_user: User = Depends(get_current_user)): + folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False) + if not folder: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found") + + path = [] + current = folder + while current: + path.insert(0, await FolderOut.from_tortoise_orm(current)) + if current.parent_id: + current = await Folder.get_or_none(id=current.parent_id, owner=current_user, is_deleted=False) + else: + current = None + + return path + @router.get("/{folder_id}", response_model=FolderOut) async def get_folder(folder_id: int, current_user: User = Depends(get_current_user)): folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False) @@ -91,6 +110,7 @@ async def update_folder(folder_id: int, folder_in: FolderUpdate, current_user: U folder.parent = new_parent_folder await folder.save() + await log_activity(current_user, "folder_updated", "folder", folder.id) return await FolderOut.from_tortoise_orm(folder) @router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -99,9 +119,9 @@ async def delete_folder(folder_id: int, current_user: User = Depends(get_current if not folder: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found") - # Soft delete folder.is_deleted = True await folder.save() + await log_activity(current_user, "folder_deleted", "folder", folder.id) return @router.post("/{folder_id}/star", response_model=FolderOut) @@ -111,6 +131,7 @@ async def star_folder(folder_id: int, current_user: User = Depends(get_current_u raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found") db_folder.is_starred = True await db_folder.save() + await log_activity(current_user, "folder_starred", "folder", folder_id) return await FolderOut.from_tortoise_orm(db_folder) @router.post("/{folder_id}/unstar", response_model=FolderOut) @@ -120,6 +141,7 @@ async def unstar_folder(folder_id: int, current_user: User = Depends(get_current raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found") db_folder.is_starred = False await db_folder.save() + await log_activity(current_user, "folder_unstarred", "folder", folder_id) return await FolderOut.from_tortoise_orm(db_folder) @router.post("/batch", response_model=List[FolderOut]) @@ -137,28 +159,32 @@ async def batch_folder_operations( if batch_operation.operation == "delete": db_folder.is_deleted = True await db_folder.save() + await log_activity(current_user, "folder_deleted", "folder", folder_id) updated_folders.append(db_folder) elif batch_operation.operation == "star": db_folder.is_starred = True await db_folder.save() + await log_activity(current_user, "folder_starred", "folder", folder_id) updated_folders.append(db_folder) elif batch_operation.operation == "unstar": db_folder.is_starred = False await db_folder.save() + await log_activity(current_user, "folder_unstarred", "folder", folder_id) updated_folders.append(db_folder) elif batch_operation.operation == "move" and payload and payload.target_folder_id is not None: target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False) if not target_folder: - continue # Skip if target folder not found + continue existing_folder = await Folder.get_or_none( name=db_folder.name, parent=target_folder, owner=current_user, is_deleted=False ) if existing_folder and existing_folder.id != folder_id: - continue # Skip if folder with same name exists + continue db_folder.parent = target_folder await db_folder.save() + await log_activity(current_user, "folder_moved", "folder", folder_id) updated_folders.append(db_folder) return [await FolderOut.from_tortoise_orm(f) for f in updated_folders] diff --git a/rbox/routers/shares.py b/rbox/routers/shares.py index 4ac2384..3ae1ba7 100644 --- a/rbox/routers/shares.py +++ b/rbox/routers/shares.py @@ -1,12 +1,14 @@ from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import StreamingResponse from typing import Optional, List import secrets from datetime import datetime, timedelta from ..auth import get_current_user from ..models import User, File, Folder, Share -from ..schemas import ShareCreate, ShareOut +from ..schemas import ShareCreate, ShareOut, FileOut, FolderOut from ..auth import get_password_hash, verify_password +from ..storage import storage_manager router = APIRouter( prefix="/shares", @@ -99,17 +101,72 @@ async def access_shared_content(share_token: str, password: Optional[str] = None share = await Share.get_or_none(token=share_token) if not share: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found") - + if share.expires_at and share.expires_at < datetime.utcnow(): raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired") if share.password_protected: if not password or not verify_password(password, share.hashed_password): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password") - - # TODO: Return actual content or a link to it based on permission_level - # For now, just indicate successful access - return {"message": "Access granted", "permission_level": share.permission_level} + + result = {"message": "Access granted", "permission_level": share.permission_level} + + if share.file_id: + file = await File.get_or_none(id=share.file_id, is_deleted=False) + if not file: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + result["file"] = await FileOut.from_tortoise_orm(file) + result["type"] = "file" + elif share.folder_id: + folder = await Folder.get_or_none(id=share.folder_id, is_deleted=False) + if not folder: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found") + result["folder"] = await FolderOut.from_tortoise_orm(folder) + result["type"] = "folder" + + files = await File.filter(parent=folder, is_deleted=False) + subfolders = await Folder.filter(parent=folder, is_deleted=False) + result["files"] = [await FileOut.from_tortoise_orm(f) for f in files] + result["folders"] = [await FolderOut.from_tortoise_orm(f) for f in subfolders] + + return result + +@router.get("/{share_token}/download") +async def download_shared_file(share_token: str, password: Optional[str] = None): + share = await Share.get_or_none(token=share_token) + if not share: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found") + + if share.expires_at and share.expires_at < datetime.utcnow(): + raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired") + + if share.password_protected: + if not password or not verify_password(password, share.hashed_password): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password") + + if not share.file_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This share is not for a file") + + file = await File.get_or_none(id=share.file_id, is_deleted=False) + if not file: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + owner = await User.get(id=file.owner_id) + + try: + async def file_iterator(): + async for chunk in storage_manager.get_file(owner.id, file.path): + yield chunk + + return StreamingResponse( + content=file_iterator(), + media_type=file.mime_type, + headers={ + "Content-Disposition": f'attachment; filename="{file.name}"' + } + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="File not found in storage") @router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_share_link(share_id: int, current_user: User = Depends(get_current_user)): diff --git a/rbox/schemas.py b/rbox/schemas.py index 87bcced..cd7630b 100644 --- a/rbox/schemas.py +++ b/rbox/schemas.py @@ -13,7 +13,7 @@ class UserLogin(BaseModel): password: str class UserLoginWith2FA(UserLogin): - two_factor_code: str + two_factor_code: Optional[str] = None class UserAdminUpdate(BaseModel): username: Optional[str] = None @@ -58,7 +58,7 @@ class TeamOut(BaseModel): created_at: datetime class Config: - orm_mode = True + from_attributes = True class ActivityOut(BaseModel): id: int @@ -70,7 +70,7 @@ class ActivityOut(BaseModel): timestamp: datetime class Config: - orm_mode = True + from_attributes = True class FileRequestCreate(BaseModel): title: str @@ -90,7 +90,7 @@ class FileRequestOut(BaseModel): is_active: bool class Config: - orm_mode = True + from_attributes = True from rbox.models import Folder, File, Share, FileVersion diff --git a/rbox/settings.py b/rbox/settings.py index a45727b..5cff292 100644 --- a/rbox/settings.py +++ b/rbox/settings.py @@ -1,28 +1,36 @@ import os +import sys from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file='.env', extra='ignore') DATABASE_URL: str = "sqlite:///app/rbox.db" - #DATABASE_URL: str = "postgres://rbox_user:rbox_password@db:5432/rbox_db" REDIS_URL: str = "redis://redis:6379/0" - SECRET_KEY: str = "super_secret_key" # CHANGE THIS IN PRODUCTION + SECRET_KEY: str = "super_secret_key" ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 DOMAIN_NAME: str = "localhost" CERTBOT_EMAIL: str = "admin@example.com" - STORAGE_PATH: str = "storage" # Path for local file storage + STORAGE_PATH: str = "storage" S3_ACCESS_KEY_ID: str | None = None S3_SECRET_ACCESS_KEY: str | None = None S3_ENDPOINT_URL: str | None = None S3_BUCKET_NAME: str = "rbox-storage" SMTP_SERVER: str | None = None - SMTP_PORT: int = 557 + SMTP_PORT: int = 587 SMTP_USERNAME: str | None = None SMTP_PASSWORD: str | None = None SMTP_FROM_EMAIL: str = "no-reply@example.com" TOTP_ISSUER: str = "RBox" + STRIPE_SECRET_KEY: str = "" + STRIPE_PUBLISHABLE_KEY: str = "" + STRIPE_WEBHOOK_SECRET: str = "" + BILLING_ENABLED: bool = False settings = Settings() + +if settings.SECRET_KEY == "super_secret_key" and os.getenv("ENVIRONMENT") == "production": + print("ERROR: Secret key must be changed in production. Set SECRET_KEY environment variable.") + sys.exit(1) diff --git a/rbox/storage.py b/rbox/storage.py index 76e0900..94b8cc9 100644 --- a/rbox/storage.py +++ b/rbox/storage.py @@ -36,7 +36,17 @@ class StorageManager: full_path = await self._get_full_path(user_id, file_path) if full_path.exists(): os.remove(full_path) - # TODO: Clean up empty directories + + parent_dir = full_path.parent + while parent_dir != self.base_path and parent_dir.exists(): + try: + if not any(parent_dir.iterdir()): + parent_dir.rmdir() + parent_dir = parent_dir.parent + else: + break + except OSError: + break async def file_exists(self, user_id: int, file_path: str) -> bool: full_path = await self._get_full_path(user_id, file_path) diff --git a/rbox/webdav.py b/rbox/webdav.py index 529c0b4..f104d4a 100644 --- a/rbox/webdav.py +++ b/rbox/webdav.py @@ -57,8 +57,8 @@ async def basic_auth(authorization: Optional[str] = Header(None)): user = await User.get_or_none(username=username) if user and verify_password(password, user.hashed_password): return user - except: - pass + except (ValueError, UnicodeDecodeError, base64.binascii.Error): + return None return None @@ -70,7 +70,7 @@ async def webdav_auth(request: Request, authorization: Optional[str] = Header(No try: user = await get_current_user(request) return user - except: + except HTTPException: pass raise HTTPException( @@ -197,8 +197,8 @@ def parse_propfind_body(body: bytes): name = child.tag.split('}')[1] if '}' in child.tag else child.tag requested_props.append((ns, name)) return requested_props - except: - pass + except ET.ParseError: + return None return None @@ -646,7 +646,7 @@ async def handle_lock(request: Request, full_path: str, current_user: User = Dep if timeout_header.startswith("Second-"): try: timeout = int(timeout_header.split("-")[1]) - except: + except (ValueError, IndexError): pass lock_token = WebDAVLock.create_lock(full_path, current_user.id, timeout) diff --git a/static/css/billing.css b/static/css/billing.css new file mode 100644 index 0000000..0bd9e0d --- /dev/null +++ b/static/css/billing.css @@ -0,0 +1,371 @@ +.billing-dashboard { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; +} + +.billing-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.subscription-badge { + padding: 0.5rem 1rem; + border-radius: 20px; + font-weight: 600; + font-size: 0.9rem; +} + +.subscription-badge.active { + background: #10b981; + color: white; +} + +.subscription-badge.inactive { + background: #ef4444; + color: white; +} + +.billing-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.billing-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.billing-card h3 { + margin: 0 0 1rem 0; + color: #1f2937; + font-size: 1.1rem; +} + +.usage-details { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.usage-item { + display: flex; + justify-content: space-between; + align-items: center; +} + +.usage-label { + color: #6b7280; + font-size: 0.9rem; +} + +.usage-value { + font-weight: 600; + font-size: 1.1rem; + color: #1f2937; +} + +.usage-progress { + width: 100%; + height: 8px; + background: #e5e7eb; + border-radius: 4px; + overflow: hidden; +} + +.usage-progress-bar { + height: 100%; + background: linear-gradient(90deg, #3b82f6, #2563eb); + transition: width 0.3s ease; +} + +.usage-info { + font-size: 0.8rem; + color: #6b7280; +} + +.estimated-cost { + font-size: 2rem; + font-weight: 700; + color: #1f2937; + margin: 1rem 0; +} + +.cost-breakdown { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e5e7eb; +} + +.cost-item { + display: flex; + justify-content: space-between; + font-size: 0.9rem; +} + +.pricing-details { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.pricing-item { + display: flex; + justify-content: space-between; + padding: 0.5rem 0; + border-bottom: 1px solid #f3f4f6; +} + +.pricing-item:last-child { + border-bottom: none; +} + +.invoices-section { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.invoices-table { + margin-top: 1rem; +} + +.invoices-table table { + width: 100%; + border-collapse: collapse; +} + +.invoices-table th, +.invoices-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid #e5e7eb; +} + +.invoices-table th { + background: #f9fafb; + font-weight: 600; + color: #374151; +} + +.invoice-status { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; +} + +.invoice-status.paid { + background: #d1fae5; + color: #065f46; +} + +.invoice-status.open { + background: #fef3c7; + color: #92400e; +} + +.invoice-status.draft { + background: #e5e7eb; + color: #374151; +} + +.no-invoices { + text-align: center; + padding: 2rem; + color: #6b7280; +} + +.payment-methods-section { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1.5rem; +} + +.btn-primary { + background: #2563eb; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.btn-primary:hover { + background: #1d4ed8; +} + +.btn-secondary { + background: #6b7280; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.btn-secondary:hover { + background: #4b5563; +} + +.btn-link { + background: none; + border: none; + color: #2563eb; + cursor: pointer; + text-decoration: underline; +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background: white; + padding: 2rem; + border-radius: 8px; + max-width: 800px; + max-height: 90vh; + overflow-y: auto; +} + +.invoice-total { + margin-top: 1rem; + padding-top: 1rem; + border-top: 2px solid #1f2937; + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 1.1rem; +} + +.admin-billing { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; +} + +.stats-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.stat-card h3 { + margin: 0 0 0.5rem 0; + color: #6b7280; + font-size: 0.9rem; + font-weight: 500; +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + color: #1f2937; +} + +.pricing-config-section { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.pricing-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} + +.pricing-table th, +.pricing-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid #e5e7eb; +} + +.pricing-table th { + background: #f9fafb; + font-weight: 600; + color: #374151; +} + +.btn-edit { + background: #3b82f6; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; +} + +.btn-edit:hover { + background: #2563eb; +} + +.invoice-generation-section { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1.5rem; +} + +.invoice-gen-form { + display: flex; + gap: 1rem; + align-items: end; + margin-top: 1rem; +} + +.invoice-gen-form label { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-weight: 500; + color: #374151; +} + +.invoice-gen-form input { + padding: 0.5rem; + border: 1px solid #e5e7eb; + border-radius: 4px; + font-size: 1rem; +} diff --git a/static/css/code-editor-view.css b/static/css/code-editor-view.css new file mode 100644 index 0000000..23ebb75 --- /dev/null +++ b/static/css/code-editor-view.css @@ -0,0 +1,80 @@ +.code-editor-view { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: white; + z-index: 100; +} + +.code-editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + border-bottom: 1px solid #ddd; + background: white; +} + +.code-editor-header .header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.code-editor-header .header-right { + display: flex; + gap: 8px; +} + +.editor-filename { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #333; +} + +.code-editor-body { + flex: 1; + overflow: hidden; + position: relative; + background: white; +} + +.code-editor-body .CodeMirror { + height: 100%; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 14px; + background: white; +} + +.code-editor-body .CodeMirror-gutters { + background: #f9f9f9; + border-right: 1px solid #ddd; +} + +.code-editor-body .CodeMirror-linenumber { + color: #999; + padding: 0 8px; +} + +.code-editor-body .CodeMirror-cursor { + border-left: 2px solid #333; +} + +.code-editor-body .CodeMirror-selected { + background: #d7e8ff; +} + +.code-editor-body .CodeMirror-line::selection, +.code-editor-body .CodeMirror-line > span::selection, +.code-editor-body .CodeMirror-line > span > span::selection { + background: #d7e8ff; +} + +.code-editor-body textarea { + display: none; +} diff --git a/static/css/file-upload-view.css b/static/css/file-upload-view.css new file mode 100644 index 0000000..966a510 --- /dev/null +++ b/static/css/file-upload-view.css @@ -0,0 +1,145 @@ +.file-upload-view { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: white; + z-index: 100; +} + +.file-upload-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + border-bottom: 1px solid #ddd; + background: white; +} + +.file-upload-header .header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.file-upload-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #333; +} + +.file-upload-body { + flex: 1; + overflow: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.drop-zone { + border: 2px dashed #ddd; + border-radius: 8px; + padding: 48px 24px; + text-align: center; + background: #f9f9f9; + transition: all 0.2s; + cursor: pointer; +} + +.drop-zone:hover { + border-color: #003399; + background: #f0f4ff; +} + +.drop-zone.drag-over { + border-color: #003399; + background: #e6f0ff; + border-style: solid; +} + +.drop-zone-icon { + font-size: 64px; + margin-bottom: 16px; + opacity: 0.5; +} + +.drop-zone h3 { + margin: 0 0 8px 0; + color: #333; + font-size: 20px; +} + +.drop-zone p { + margin: 8px 0; + color: #666; +} + +.upload-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.upload-item { + background: white; + border: 1px solid #ddd; + border-radius: 8px; + padding: 16px; +} + +.upload-item-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.upload-item-name { + font-weight: 500; + color: #333; +} + +.upload-item-size { + color: #666; + font-size: 14px; +} + +.upload-item-progress { + display: flex; + flex-direction: column; + gap: 4px; +} + +.progress-bar { + width: 100%; + height: 8px; + background: #e0e0e0; + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: #003399; + transition: width 0.3s ease; +} + +.upload-item-status { + font-size: 14px; + color: #666; +} + +.upload-item-status.success { + color: #28a745; + font-weight: 500; +} + +.upload-item-status.error { + color: #dc3545; + font-weight: 500; +} diff --git a/static/css/style.css b/static/css/style.css index d8d6924..407db68 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -16,6 +16,12 @@ * { box-sizing: border-box; user-select: none; + scrollbar-width: none; + -ms-overflow-style: none; +} + +*::-webkit-scrollbar { + display: none; } input, textarea { @@ -29,6 +35,7 @@ body { background-color: var(--background-color); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + overflow: hidden; } .app-container { @@ -146,6 +153,7 @@ body { flex: 1; overflow-y: auto; padding: calc(var(--spacing-unit) * 3); + position: relative; } .button { @@ -267,6 +275,41 @@ body { padding: calc(var(--spacing-unit) * 2); } +.breadcrumb-nav { + display: flex; + align-items: center; + gap: calc(var(--spacing-unit) * 1); + margin-bottom: calc(var(--spacing-unit) * 2); + font-size: 14px; + color: var(--text-color-light); +} + +.breadcrumb-item { + cursor: pointer; + color: var(--primary-color); + transition: color 0.2s; +} + +.breadcrumb-item:hover { + color: var(--secondary-color); + text-decoration: underline; +} + +.breadcrumb-item.breadcrumb-current { + color: var(--text-color); + cursor: default; + font-weight: 600; +} + +.breadcrumb-item.breadcrumb-current:hover { + text-decoration: none; +} + +.breadcrumb-separator { + color: var(--text-color-light); + user-select: none; +} + .file-list-header { display: flex; justify-content: space-between; @@ -276,11 +319,36 @@ body { border-bottom: 1px solid var(--border-color); } +.file-list-header .header-left { + display: flex; + align-items: center; + gap: calc(var(--spacing-unit) * 3); +} + .file-list-header h2 { margin: 0; color: var(--primary-color); } +.selection-controls { + display: flex; + align-items: center; + gap: var(--spacing-unit); +} + +.selection-controls input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.selection-controls label { + font-size: 14px; + color: var(--text-color); + cursor: pointer; + user-select: none; +} + .file-actions { display: flex; gap: var(--spacing-unit); @@ -301,6 +369,7 @@ body { border-radius: 8px; cursor: pointer; transition: all 0.2s ease; + position: relative; } .file-item:hover { @@ -308,6 +377,10 @@ body { border-color: var(--primary-color); } +.file-item:hover .select-item { + opacity: 1; +} + .file-icon { font-size: 3rem; margin-bottom: var(--spacing-unit); @@ -690,66 +763,54 @@ body.dark-mode { color: var(--text-color-light); } -.preview-modal { - position: fixed; +.file-preview-overlay { + position: absolute; top: 0; left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.9); - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - padding: calc(var(--spacing-unit) * 2); -} - -.preview-container { - background: var(--accent-color); - border-radius: 8px; - max-width: 90vw; - max-height: 90vh; + width: 100%; + height: 100%; display: flex; flex-direction: column; - overflow: hidden; + background: white; + z-index: 100; } -.preview-header { +.file-preview-header { display: flex; justify-content: space-between; align-items: center; - padding: calc(var(--spacing-unit) * 2); - border-bottom: 1px solid var(--border-color); + padding: 16px 24px; + border-bottom: 1px solid #ddd; + background: white; } -.preview-info h3 { - margin: 0 0 calc(var(--spacing-unit) * 0.5) 0; - font-size: 1.25rem; +.file-preview-header .header-left { + display: flex; + align-items: center; + gap: 16px; } -.preview-info p { +.preview-info { + display: flex; + flex-direction: column; +} + +.preview-file-name { margin: 0; - color: var(--text-color-light); - font-size: 0.875rem; + font-size: 18px; + font-weight: 600; + color: #333; +} + +.preview-file-info { + margin: 4px 0 0 0; + color: #666; + font-size: 14px; } .preview-actions { display: flex; - gap: calc(var(--spacing-unit) * 1); -} - -.btn-icon { - background: none; - border: 1px solid var(--border-color); - padding: calc(var(--spacing-unit) * 1); - border-radius: 4px; - cursor: pointer; - font-size: 1.2rem; - transition: background 0.2s; -} - -.btn-icon:hover { - background: var(--background-color); + gap: 8px; } .preview-content { @@ -759,7 +820,7 @@ body.dark-mode { align-items: center; justify-content: center; padding: calc(var(--spacing-unit) * 2); - background: #000; + background: #f9f9f9; } .preview-content img, @@ -894,4 +955,18 @@ body.dark-mode { top: var(--spacing-unit); left: var(--spacing-unit); z-index: 1; + width: 20px; + height: 20px; + cursor: pointer; + opacity: 0.3; + transition: opacity 0.2s ease; +} + +.file-item .select-item:checked { + opacity: 1; +} + +.file-item.selected { + border-color: var(--primary-color); + background-color: rgba(0, 51, 153, 0.05); } diff --git a/static/icons/icon-192x192.png b/static/icons/icon-192x192.png index e69de29..d2a1369 100644 Binary files a/static/icons/icon-192x192.png and b/static/icons/icon-192x192.png differ diff --git a/static/icons/icon-256x256.png b/static/icons/icon-256x256.png new file mode 100644 index 0000000..410899d Binary files /dev/null and b/static/icons/icon-256x256.png differ diff --git a/static/icons/icon-384x384.png b/static/icons/icon-384x384.png new file mode 100644 index 0000000..8df975f Binary files /dev/null and b/static/icons/icon-384x384.png differ diff --git a/static/icons/icon-512x512.png b/static/icons/icon-512x512.png index e69de29..e14c5b5 100644 Binary files a/static/icons/icon-512x512.png and b/static/icons/icon-512x512.png differ diff --git a/static/index.html b/static/index.html index ef0e50a..819cd14 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,19 @@ RBox Cloud Storage + + + + + + + + + + + + diff --git a/static/js/api.js b/static/js/api.js index c65792a..5ef4022 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -113,6 +113,10 @@ class APIClient { }); } + async getFolderPath(folderId) { + return this.request(`folders/${folderId}/path`); + } + async deleteFolder(folderId) { return this.request(`folders/${folderId}`, { method: 'DELETE' @@ -294,12 +298,6 @@ class APIClient { }); } - async deleteShare(shareId) { - return this.request(`shares/${shareId}`, { - method: 'DELETE' - }); - } - async batchFileOperations(operation, fileIds, targetFolderId = null) { const payload = { file_ids: fileIds, operation: operation }; if (targetFolderId !== null) { @@ -321,6 +319,13 @@ class APIClient { body: payload }); } + + async updateFile(fileId, content) { + return this.request(`files/${fileId}/content`, { + method: 'PUT', + body: { content } + }); + } } export const api = new APIClient(); diff --git a/static/js/components/admin-billing.js b/static/js/components/admin-billing.js new file mode 100644 index 0000000..06d93f6 --- /dev/null +++ b/static/js/components/admin-billing.js @@ -0,0 +1,193 @@ +class AdminBilling extends HTMLElement { + constructor() { + super(); + this.pricingConfig = []; + this.stats = null; + this.boundHandleClick = this.handleClick.bind(this); + } + + async connectedCallback() { + this.addEventListener('click', this.boundHandleClick); + await this.loadData(); + this.render(); + this.attachEventListeners(); + } + + disconnectedCallback() { + this.removeEventListener('click', this.boundHandleClick); + } + + async loadData() { + try { + const [pricing, stats] = await Promise.all([ + this.fetchPricing(), + this.fetchStats() + ]); + + this.pricingConfig = pricing; + this.stats = stats; + } catch (error) { + console.error('Failed to load admin billing data:', error); + } + } + + async fetchPricing() { + const response = await fetch('/api/admin/billing/pricing', { + headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`} + }); + return await response.json(); + } + + async fetchStats() { + const response = await fetch('/api/admin/billing/stats', { + headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`} + }); + return await response.json(); + } + + formatCurrency(amount) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(amount); + } + + render() { + this.innerHTML = ` +
+

Billing Administration

+ +
+
+

Total Revenue

+
${this.formatCurrency(this.stats?.total_revenue || 0)}
+
+
+

Total Invoices

+
${this.stats?.total_invoices || 0}
+
+
+

Pending Invoices

+
${this.stats?.pending_invoices || 0}
+
+
+ +
+

Pricing Configuration

+ + + + + + + + + + + ${this.pricingConfig.map(config => ` + + + + + + + `).join('')} + +
ConfigurationCurrent ValueUnitActions
${config.description || config.config_key}${config.config_value}${config.unit || '-'} + +
+
+ +
+

Generate Invoices

+
+ + + +
+
+
+ `; + } + + handleClick(e) { + const target = e.target; + + if (target.classList.contains('btn-edit')) { + const configId = target.dataset.configId; + this.editPricing(configId); + return; + } + + if (target.id === 'generateInvoices') { + this.generateInvoices(); + } + } + + attachEventListeners() { + } + + async editPricing(configId) { + const config = this.pricingConfig.find(c => c.id === parseInt(configId)); + if (!config) return; + + const newValue = prompt(`Enter new value for ${config.config_key}:`, config.config_value); + if (newValue === null) return; + + try { + const response = await fetch(`/api/admin/billing/pricing/${configId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify({ + config_key: config.config_key, + config_value: parseFloat(newValue) + }) + }); + + if (response.ok) { + alert('Pricing updated successfully'); + await this.loadData(); + this.render(); + this.attachEventListeners(); + } else { + alert('Failed to update pricing'); + } + } catch (error) { + console.error('Error updating pricing:', error); + alert('Error updating pricing'); + } + } + + async generateInvoices() { + const year = parseInt(this.querySelector('#invoiceYear').value); + const month = parseInt(this.querySelector('#invoiceMonth').value); + + if (!confirm(`Generate invoices for ${month}/${year}?`)) return; + + try { + const response = await fetch(`/api/admin/billing/generate-invoices/${year}/${month}`, { + method: 'POST', + headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`} + }); + + const result = await response.json(); + alert(`Generated ${result.generated} invoices, skipped ${result.skipped} users`); + } catch (error) { + console.error('Error generating invoices:', error); + alert('Failed to generate invoices'); + } + } +} + +customElements.define('admin-billing', AdminBilling); + +export default AdminBilling; diff --git a/static/js/components/admin-dashboard.js b/static/js/components/admin-dashboard.js index 092b8b3..f71580c 100644 --- a/static/js/components/admin-dashboard.js +++ b/static/js/components/admin-dashboard.js @@ -4,12 +4,21 @@ export class AdminDashboard extends HTMLElement { constructor() { super(); this.users = []; + this.boundHandleClick = this.handleClick.bind(this); + this.boundHandleSubmit = this.handleSubmit.bind(this); } async connectedCallback() { + this.addEventListener('click', this.boundHandleClick); + this.addEventListener('submit', this.boundHandleSubmit); await this.loadUsers(); } + disconnectedCallback() { + this.removeEventListener('click', this.boundHandleClick); + this.removeEventListener('submit', this.boundHandleSubmit); + } + async loadUsers() { try { this.users = await api.listUsers(); @@ -61,24 +70,35 @@ export class AdminDashboard extends HTMLElement { `; - - this.querySelector('#createUserButton').addEventListener('click', () => this._showUserModal()); - this.querySelector('.user-list').addEventListener('click', this._handleUserAction.bind(this)); - this.querySelector('.close-button').addEventListener('click', () => this.querySelector('#userModal').style.display = 'none'); - this.querySelector('#userForm').addEventListener('submit', this._handleUserFormSubmit.bind(this)); } - _handleUserAction(event) { - const target = event.target; + handleClick(e) { + const target = e.target; + + if (target.id === 'createUserButton') { + this._showUserModal(); + return; + } + + if (target.classList.contains('close-button')) { + this.querySelector('#userModal').style.display = 'none'; + return; + } + const userItem = target.closest('.user-item'); - if (!userItem) return; + if (userItem) { + const userId = userItem.dataset.userId; + if (target.classList.contains('button-danger')) { + this._deleteUser(userId); + } else if (target.classList.contains('button')) { + this._showUserModal(userId); + } + } + } - const userId = userItem.dataset.userId; // Assuming user ID will be stored in data-userId attribute - - if (target.classList.contains('button-danger')) { - this._deleteUser(userId); - } else if (target.classList.contains('button')) { // Edit button - this._showUserModal(userId); + handleSubmit(e) { + if (e.target.id === 'userForm') { + this._handleUserFormSubmit(e); } } diff --git a/static/js/components/base-file-list.js b/static/js/components/base-file-list.js new file mode 100644 index 0000000..3a14b7e --- /dev/null +++ b/static/js/components/base-file-list.js @@ -0,0 +1,268 @@ +export class BaseFileList extends HTMLElement { + constructor() { + super(); + this.files = []; + this.folders = []; + this.selectedFiles = new Set(); + this.selectedFolders = new Set(); + this.boundHandleClick = this.handleClick.bind(this); + this.boundHandleDblClick = this.handleDblClick.bind(this); + this.boundHandleChange = this.handleChange.bind(this); + } + + connectedCallback() { + this.addEventListener('click', this.boundHandleClick); + this.addEventListener('dblclick', this.boundHandleDblClick); + this.addEventListener('change', this.boundHandleChange); + } + + disconnectedCallback() { + this.removeEventListener('click', this.boundHandleClick); + this.removeEventListener('dblclick', this.boundHandleDblClick); + this.removeEventListener('change', this.boundHandleChange); + } + + isEditableFile(filename, mimeType) { + if (mimeType && mimeType.startsWith('text/')) return true; + + const editableExtensions = [ + 'txt', 'md', 'log', 'json', 'js', 'py', 'html', 'css', + 'xml', 'yaml', 'yml', 'sh', 'bat', 'ini', 'conf', 'cfg' + ]; + const extension = filename.split('.').pop().toLowerCase(); + return editableExtensions.includes(extension); + } + + getFileIcon(mimeType) { + if (mimeType.startsWith('image/')) return '📷'; + if (mimeType.startsWith('video/')) return '🎥'; + if (mimeType.startsWith('audio/')) return '🎵'; + if (mimeType.includes('pdf')) return '📄'; + if (mimeType.includes('text')) return '📄'; + return '📄'; + } + + formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; + return (bytes / 1073741824).toFixed(1) + ' GB'; + } + + renderFolder(folder) { + const isSelected = this.selectedFolders.has(folder.id); + const starIcon = folder.is_starred ? '★' : '☆'; + const starAction = folder.is_starred ? 'unstar-folder' : 'star-folder'; + const actions = this.getFolderActions(folder); + + return ` +
+ +
📁
+
${folder.name}
+
+ ${actions} +
+
+ `; + } + + renderFile(file) { + const isSelected = this.selectedFiles.has(file.id); + const icon = this.getFileIcon(file.mime_type); + const size = this.formatFileSize(file.size); + const starIcon = file.is_starred ? '★' : '☆'; + const starAction = file.is_starred ? 'unstar-file' : 'star-file'; + const actions = this.getFileActions(file); + + return ` +
+ +
${icon}
+
${file.name}
+
${size}
+
+ ${actions} +
+
+ `; + } + + getFolderActions(folder) { + return ''; + } + + getFileActions(file) { + return ''; + } + + handleClick(e) { + const target = e.target; + + if (target.id === 'clear-selection-btn') { + this.clearSelection(); + return; + } + + if (target.classList.contains('action-btn')) { + e.stopPropagation(); + const action = target.dataset.action; + const id = parseInt(target.dataset.id); + this.handleAction(action, id); + return; + } + + if (target.classList.contains('select-item')) { + e.stopPropagation(); + return; + } + + const fileItem = target.closest('.file-item:not(.folder-item)'); + if (fileItem) { + const fileId = parseInt(fileItem.dataset.fileId); + const file = this.files.find(f => f.id === fileId); + + if (this.isEditableFile(file.name, file.mime_type)) { + this.dispatchEvent(new CustomEvent('edit-file', { + detail: { file: file }, + bubbles: true + })); + } else { + this.dispatchEvent(new CustomEvent('photo-click', { + detail: { photo: file }, + bubbles: true + })); + } + } + } + + handleDblClick(e) { + const folderItem = e.target.closest('.folder-item'); + if (folderItem) { + const folderId = parseInt(folderItem.dataset.folderId); + this.dispatchEvent(new CustomEvent('folder-open', { detail: { folderId } })); + } + } + + handleChange(e) { + const target = e.target; + + if (target.id === 'select-all') { + this.toggleSelectAll(target.checked); + return; + } + + if (target.classList.contains('select-item')) { + const type = target.dataset.type; + const id = parseInt(target.dataset.id); + this.toggleSelectItem(type, id, target.checked); + } + } + + toggleSelectItem(type, id, checked) { + if (type === 'file') { + if (checked) { + this.selectedFiles.add(id); + } else { + this.selectedFiles.delete(id); + } + } else if (type === 'folder') { + if (checked) { + this.selectedFolders.add(id); + } else { + this.selectedFolders.delete(id); + } + } + + const item = this.querySelector(`[data-${type}-id="${id}"]`); + if (item) { + if (checked) { + item.classList.add('selected'); + } else { + item.classList.remove('selected'); + } + } + + this.updateSelectionUI(); + } + + toggleSelectAll(checked) { + this.selectedFiles.clear(); + this.selectedFolders.clear(); + + if (checked) { + this.files.forEach(file => this.selectedFiles.add(file.id)); + this.folders.forEach(folder => this.selectedFolders.add(folder.id)); + } + + this.querySelectorAll('.select-item').forEach(checkbox => { + checkbox.checked = checked; + }); + + this.querySelectorAll('.file-item').forEach(item => { + if (checked) { + item.classList.add('selected'); + } else { + item.classList.remove('selected'); + } + }); + + this.updateSelectionUI(); + } + + clearSelection() { + this.selectedFiles.clear(); + this.selectedFolders.clear(); + this.querySelectorAll('.select-item').forEach(checkbox => { + checkbox.checked = false; + }); + this.querySelectorAll('.file-item').forEach(item => { + item.classList.remove('selected'); + }); + this.updateSelectionUI(); + } + + updateSelectionUI() { + const hasSelected = this.selectedFiles.size > 0 || this.selectedFolders.size > 0; + const totalItems = this.files.length + this.folders.length; + const totalSelected = this.selectedFiles.size + this.selectedFolders.size; + const allSelected = totalItems > 0 && totalSelected === totalItems; + + const selectAllCheckbox = this.querySelector('#select-all'); + const selectAllLabel = this.querySelector('label[for="select-all"]'); + const batchActionsDiv = this.querySelector('.batch-actions'); + + if (selectAllCheckbox) { + selectAllCheckbox.checked = allSelected; + this.updateIndeterminateState(); + } + + if (selectAllLabel) { + selectAllLabel.textContent = hasSelected ? `${totalSelected} selected` : 'Select all'; + } + + if (hasSelected && !batchActionsDiv) { + this.createBatchActionsBar(); + } else if (!hasSelected && batchActionsDiv) { + batchActionsDiv.remove(); + } + } + + createBatchActionsBar() { + } + + updateIndeterminateState() { + const selectAllCheckbox = this.querySelector('#select-all'); + if (selectAllCheckbox) { + const totalItems = this.files.length + this.folders.length; + const totalSelected = this.selectedFiles.size + this.selectedFolders.size; + const hasSelected = totalSelected > 0; + const allSelected = totalItems > 0 && totalSelected === totalItems; + + selectAllCheckbox.indeterminate = hasSelected && !allSelected; + } + } + + async handleAction(action, id) { + } +} diff --git a/static/js/components/billing-dashboard.js b/static/js/components/billing-dashboard.js new file mode 100644 index 0000000..bfd1702 --- /dev/null +++ b/static/js/components/billing-dashboard.js @@ -0,0 +1,309 @@ +class BillingDashboard extends HTMLElement { + constructor() { + super(); + this.currentUsage = null; + this.subscription = null; + this.pricing = null; + this.invoices = []; + this.boundHandleClick = this.handleClick.bind(this); + } + + async connectedCallback() { + this.addEventListener('click', this.boundHandleClick); + await this.loadData(); + this.render(); + this.attachEventListeners(); + } + + disconnectedCallback() { + this.removeEventListener('click', this.boundHandleClick); + } + + async loadData() { + try { + const [usage, subscription, pricing, invoices] = await Promise.all([ + this.fetchCurrentUsage(), + this.fetchSubscription(), + this.fetchPricing(), + this.fetchInvoices() + ]); + + this.currentUsage = usage; + this.subscription = subscription; + this.pricing = pricing; + this.invoices = invoices; + } catch (error) { + console.error('Failed to load billing data:', error); + } + } + + async fetchCurrentUsage() { + const response = await fetch('/api/billing/usage/current', { + headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`} + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } + + async fetchSubscription() { + const response = await fetch('/api/billing/subscription', { + headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`} + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } + + async fetchPricing() { + const response = await fetch('/api/billing/pricing'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } + + async fetchInvoices() { + const response = await fetch('/api/billing/invoices?limit=10', { + headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`} + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } + + formatCurrency(amount) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 4 + }).format(amount); + } + + formatGB(gb) { + if (gb >= 1024) { + return `${(gb / 1024).toFixed(2)} TB`; + } + return `${gb.toFixed(2)} GB`; + } + + calculateEstimatedCost() { + if (!this.currentUsage || !this.pricing) return 0; + + const storagePrice = parseFloat(this.pricing.storage_per_gb_month?.value || 0); + const bandwidthPrice = parseFloat(this.pricing.bandwidth_egress_per_gb?.value || 0); + const freeStorage = parseFloat(this.pricing.free_tier_storage_gb?.value || 0); + const freeBandwidth = parseFloat(this.pricing.free_tier_bandwidth_gb?.value || 0); + + const storageGB = this.currentUsage.storage_gb; + const bandwidthGB = this.currentUsage.bandwidth_down_gb_today * 30; + + const billableStorage = Math.max(0, Math.ceil(storageGB - freeStorage)); + const billableBandwidth = Math.max(0, Math.ceil(bandwidthGB - freeBandwidth)); + + return (billableStorage * storagePrice) + (billableBandwidth * bandwidthPrice); + } + + render() { + const estimatedCost = this.calculateEstimatedCost(); + const storageUsed = this.currentUsage?.storage_gb || 0; + const freeStorage = parseFloat(this.pricing?.free_tier_storage_gb?.value || 15); + const storagePercentage = Math.min(100, (storageUsed / freeStorage) * 100); + + this.innerHTML = ` +
+
+

Billing & Usage

+
+ ${this.subscription?.billing_type === 'pay_as_you_go' ? 'Pay As You Go' : this.subscription?.plan_name || 'Free'} +
+
+ +
+
+

Current Usage

+
+
+ Storage + ${this.formatGB(storageUsed)} +
+
+
+
+
${this.formatGB(freeStorage)} included free
+ +
+ Bandwidth (Today) + ${this.formatGB(this.currentUsage?.bandwidth_down_gb_today || 0)} +
+
+
+ +
+

Estimated Monthly Cost

+
${this.formatCurrency(estimatedCost)}
+
+
+ Storage + ${this.formatCurrency(Math.max(0, Math.ceil(storageUsed - freeStorage)) * parseFloat(this.pricing?.storage_per_gb_month?.value || 0))} +
+
+ Bandwidth + ${this.formatCurrency(0)} +
+
+
+ +
+

Current Pricing

+
+
+ Storage + ${this.formatCurrency(parseFloat(this.pricing?.storage_per_gb_month?.value || 0))}/GB/month +
+
+ Bandwidth + ${this.formatCurrency(parseFloat(this.pricing?.bandwidth_egress_per_gb?.value || 0))}/GB +
+
+ Free Tier + ${this.formatGB(freeStorage)} storage, ${this.formatGB(parseFloat(this.pricing?.free_tier_bandwidth_gb?.value || 15))} bandwidth/month +
+
+
+
+ +
+

Recent Invoices

+
+ ${this.renderInvoicesTable()} +
+
+ +
+

Payment Methods

+ +
+
+ `; + } + + renderInvoicesTable() { + if (!this.invoices || this.invoices.length === 0) { + return '

No invoices yet

'; + } + + return ` + + + + + + + + + + + + + ${this.invoices.map(invoice => ` + + + + + + + + + `).join('')} + +
Invoice #PeriodAmountStatusDue DateActions
${invoice.invoice_number}${this.formatDate(invoice.period_start)} - ${this.formatDate(invoice.period_end)}${this.formatCurrency(invoice.total)}${invoice.status}${invoice.due_date ? this.formatDate(invoice.due_date) : '-'} + +
+ `; + } + + formatDate(dateString) { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } + + handleClick(e) { + const target = e.target; + + if (target.id === 'addPaymentMethod') { + this.showPaymentMethodModal(); + return; + } + + if (target.dataset.invoiceId) { + const invoiceId = target.dataset.invoiceId; + this.showInvoiceDetail(invoiceId); + } + } + + attachEventListeners() { + } + + async showPaymentMethodModal() { + alert('Payment method modal will be implemented with Stripe Elements'); + } + + async showInvoiceDetail(invoiceId) { + const response = await fetch(`/api/billing/invoices/${invoiceId}`, { + headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`} + }); + const invoice = await response.json(); + + const modal = document.createElement('div'); + modal.className = 'modal'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + } +} + +customElements.define('billing-dashboard', BillingDashboard); + +export default BillingDashboard; diff --git a/static/js/components/code-editor-view.js b/static/js/components/code-editor-view.js new file mode 100644 index 0000000..39014f4 --- /dev/null +++ b/static/js/components/code-editor-view.js @@ -0,0 +1,153 @@ +import { api } from '../api.js'; + +class CodeEditorView extends HTMLElement { + constructor() { + super(); + this.editor = null; + this.file = null; + this.previousView = null; + this.boundHandleClick = this.handleClick.bind(this); + this.boundHandleEscape = this.handleEscape.bind(this); + } + + connectedCallback() { + this.addEventListener('click', this.boundHandleClick); + document.addEventListener('keydown', this.boundHandleEscape); + } + + disconnectedCallback() { + this.removeEventListener('click', this.boundHandleClick); + document.removeEventListener('keydown', this.boundHandleEscape); + if (this.editor) { + this.editor.toTextArea(); + this.editor = null; + } + } + + handleEscape(e) { + if (e.key === 'Escape') { + this.goBack(); + } + } + + async setFile(file, previousView = 'files') { + this.file = file; + this.previousView = previousView; + await this.loadAndRender(); + } + + async loadAndRender() { + try { + const blob = await api.downloadFile(this.file.id); + const content = await blob.text(); + this.render(content); + this.initializeEditor(content); + } catch (error) { + console.error('Failed to load file:', error); + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Failed to load file: ' + error.message, type: 'error' } + })); + this.render(''); + window.history.back(); + } + } + + getMimeType(filename) { + const extension = filename.split('.').pop().toLowerCase(); + const mimeMap = { + 'js': 'text/javascript', + 'json': 'application/json', + 'py': 'text/x-python', + 'md': 'text/x-markdown', + 'html': 'text/html', + 'xml': 'application/xml', + 'css': 'text/css', + 'txt': 'text/plain', + 'log': 'text/plain', + 'sh': 'text/x-sh', + 'yaml': 'text/x-yaml', + 'yml': 'text/x-yaml' + }; + return mimeMap[extension] || 'text/plain'; + } + + render(content) { + this.innerHTML = ` +
+
+
+ +

${this.file.name}

+
+
+ +
+
+
+ +
+
+ `; + } + + initializeEditor(content) { + const textarea = this.querySelector('#code-editor-textarea'); + if (!textarea) return; + + this.editor = CodeMirror.fromTextArea(textarea, { + value: content, + mode: this.getMimeType(this.file.name), + lineNumbers: true, + theme: 'default', + lineWrapping: true, + indentUnit: 4, + indentWithTabs: false, + extraKeys: { + 'Ctrl-S': () => this.save(), + 'Cmd-S': () => this.save() + } + }); + + this.editor.setSize('100%', '100%'); + } + + handleClick(e) { + if (e.target.id === 'back-btn') { + this.goBack(); + } else if (e.target.id === 'save-btn') { + this.save(); + } + } + + async save() { + if (!this.editor) return; + + try { + const content = this.editor.getValue(); + await api.updateFile(this.file.id, content); + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'File saved successfully!', type: 'success' } + })); + } catch (error) { + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Failed to save file: ' + error.message, type: 'error' } + })); + } + } + + goBack() { + window.history.back(); + } + + hide() { + document.removeEventListener('keydown', this.boundHandleEscape); + if (this.editor) { + this.editor.toTextArea(); + this.editor = null; + } + this.remove(); + } +} + +customElements.define('code-editor-view', CodeEditorView); +export { CodeEditorView }; diff --git a/static/js/components/deleted-files.js b/static/js/components/deleted-files.js index f7e491f..b6ea05c 100644 --- a/static/js/components/deleted-files.js +++ b/static/js/components/deleted-files.js @@ -1,18 +1,22 @@ import { api } from '../api.js'; +import { BaseFileList } from './base-file-list.js'; -export class DeletedFiles extends HTMLElement { +export class DeletedFiles extends BaseFileList { constructor() { super(); - this.deletedFiles = []; } async connectedCallback() { + super.connectedCallback(); await this.loadDeletedFiles(); } async loadDeletedFiles() { try { - this.deletedFiles = await api.listDeletedFiles(); + this.files = await api.listDeletedFiles(); + this.folders = []; + this.selectedFiles.clear(); + this.selectedFolders.clear(); this.render(); } catch (error) { console.error('Failed to load deleted files:', error); @@ -21,10 +25,17 @@ export class DeletedFiles extends HTMLElement { } render() { - if (this.deletedFiles.length === 0) { + const hasSelected = this.selectedFiles.size > 0; + const totalSelected = this.selectedFiles.size; + const allSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length; + const someSelected = hasSelected && !allSelected; + + if (this.files.length === 0) { this.innerHTML = ` -
-

Deleted Files

+
+
+

Deleted Files

+

No deleted files found.

`; @@ -32,76 +43,110 @@ export class DeletedFiles extends HTMLElement { } this.innerHTML = ` -
-

Deleted Files

+
+
+
+

Deleted Files

+ ${this.files.length > 0 ? ` +
+ + +
+ ` : ''} +
+
+ + ${hasSelected ? ` +
+ + +
+ ` : ''} +
- ${this.deletedFiles.map(file => this.renderDeletedFile(file)).join('')} + ${this.files.map(file => this.renderDeletedFile(file)).join('')}
`; this.attachListeners(); + this.updateIndeterminateState(); } renderDeletedFile(file) { + const isSelected = this.selectedFiles.has(file.id); const icon = this.getFileIcon(file.mime_type); const size = this.formatFileSize(file.size); - const deletedDate = new Date(file.deleted_at).toLocaleDateString(); + const deletedAt = file.deleted_at ? new Date(file.deleted_at).toLocaleString() : 'N/A'; return ` -
+
+
${icon}
${file.name}
${size}
-
Deleted: ${deletedDate}
+
Deleted: ${deletedAt}
- +
`; } - getFileIcon(mimeType) { - if (mimeType.startsWith('image/')) return '📷'; - if (mimeType.startsWith('video/')) return '🎥'; - if (mimeType.startsWith('audio/')) return '🎵'; - if (mimeType.includes('pdf')) return '📄'; - if (mimeType.includes('text')) return '📄'; - return '📄'; - } - - formatFileSize(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; - if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; - return (bytes / 1073741824).toFixed(1) + ' GB'; + createBatchActionsBar() { + const container = this.querySelector('.file-list-container'); + const header = container.querySelector('.file-list-header'); + const batchBar = document.createElement('div'); + batchBar.className = 'batch-actions'; + batchBar.innerHTML = ` + + + `; + header.insertAdjacentElement('afterend', batchBar); } attachListeners() { - this.querySelectorAll('.action-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const action = btn.dataset.action; - const id = parseInt(btn.dataset.id); - if (action === 'restore') { - await this.handleRestore(id); - } - }); - }); + const batchRestoreBtn = this.querySelector('#batch-restore-btn'); + if (batchRestoreBtn) { + batchRestoreBtn.addEventListener('click', () => this.handleBatchRestore()); + } } - async handleRestore(fileId) { - if (confirm('Are you sure you want to restore this file?')) { - try { + async handleBatchRestore() { + const totalSelected = this.selectedFiles.size; + if (totalSelected === 0) return; + + if (!confirm(`Restore ${totalSelected} files?`)) return; + + try { + for (const fileId of this.selectedFiles) { await api.restoreFile(fileId); + } + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Files restored successfully!', type: 'success' } + })); + await this.loadDeletedFiles(); + } catch (error) { + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Failed to restore files: ' + error.message, type: 'error' } + })); + } + } + + async handleAction(action, id) { + try { + if (action === 'restore') { + await api.restoreFile(id); document.dispatchEvent(new CustomEvent('show-toast', { detail: { message: 'File restored successfully!', type: 'success' } })); - await this.loadDeletedFiles(); // Reload the list - } catch (error) { - document.dispatchEvent(new CustomEvent('show-toast', { - detail: { message: 'Failed to restore file: ' + error.message, type: 'error' } - })); + await this.loadDeletedFiles(); } + } catch (error) { + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Action failed: ' + error.message, type: 'error' } + })); } } } diff --git a/static/js/components/file-list.js b/static/js/components/file-list.js index 6d44ffe..88e14d4 100644 --- a/static/js/components/file-list.js +++ b/static/js/components/file-list.js @@ -4,29 +4,54 @@ export class FileList extends HTMLElement { constructor() { super(); this.currentFolderId = null; + this.folderPath = []; this.files = []; this.folders = []; this.selectedFiles = new Set(); this.selectedFolders = new Set(); + this.boundHandleClick = this.handleClick.bind(this); + this.boundHandleDblClick = this.handleDblClick.bind(this); + this.boundHandleChange = this.handleChange.bind(this); } async connectedCallback() { + this.addEventListener('click', this.boundHandleClick); + this.addEventListener('dblclick', this.boundHandleDblClick); + this.addEventListener('change', this.boundHandleChange); await this.loadContents(null); } + disconnectedCallback() { + this.removeEventListener('click', this.boundHandleClick); + this.removeEventListener('dblclick', this.boundHandleDblClick); + this.removeEventListener('change', this.boundHandleChange); + } + async loadContents(folderId) { this.currentFolderId = folderId; try { + if (folderId) { + try { + this.folderPath = await api.getFolderPath(folderId); + } catch (pathError) { + this.folderPath = []; + } + } else { + this.folderPath = []; + } this.folders = await api.listFolders(folderId); this.files = await api.listFiles(folderId); this.selectedFiles.clear(); this.selectedFolders.clear(); this.render(); } catch (error) { - console.error('Failed to load contents:', error); document.dispatchEvent(new CustomEvent('show-toast', { - detail: { message: 'Failed to load contents: ' + error.message, type: 'error' } + detail: { message: 'Failed to load folder contents', type: 'error' } })); + this.folderPath = []; + this.folders = []; + this.files = []; + this.render(); } } @@ -40,29 +65,52 @@ export class FileList extends HTMLElement { render() { const hasSelected = this.selectedFiles.size > 0 || this.selectedFolders.size > 0; + const totalItems = this.files.length + this.folders.length; + const totalSelected = this.selectedFiles.size + this.selectedFolders.size; const allFilesSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length; const allFoldersSelected = this.folders.length > 0 && this.selectedFolders.size === this.folders.length; - const allSelected = (this.files.length + this.folders.length) > 0 && allFilesSelected && allFoldersSelected; + const allSelected = totalItems > 0 && allFilesSelected && allFoldersSelected; + const someSelected = hasSelected && !allSelected; this.innerHTML = `
+ ${this.folderPath.length > 0 ? ` + + ` : ''}
-

Files

+
+

Files

+ ${totalItems > 0 ? ` +
+ + +
+ ` : ''} +
-
- - - - - - - -
+ ${hasSelected ? ` +
+ + + + + + +
+ ` : ''}
${this.folders.map(folder => this.renderFolder(folder)).join('')} @@ -72,6 +120,7 @@ export class FileList extends HTMLElement { `; this.attachListeners(); + this.updateIndeterminateState(); } renderFolder(folder) { @@ -124,6 +173,17 @@ export class FileList extends HTMLElement { return '📄'; } + isEditableFile(filename, mimeType) { + if (mimeType && mimeType.startsWith('text/')) return true; + + const editableExtensions = [ + 'txt', 'md', 'log', 'json', 'js', 'py', 'html', 'css', + 'xml', 'yaml', 'yml', 'sh', 'bat', 'ini', 'conf', 'cfg' + ]; + const extension = filename.split('.').pop().toLowerCase(); + return editableExtensions.includes(extension); + } + formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; @@ -131,62 +191,116 @@ export class FileList extends HTMLElement { return (bytes / 1073741824).toFixed(1) + ' GB'; } + handleClick(e) { + const target = e.target; + + if (target.classList.contains('breadcrumb-item') && !target.classList.contains('breadcrumb-current')) { + const folderId = target.dataset.folderId; + const targetFolderId = folderId === 'null' ? null : parseInt(folderId); + this.loadContents(targetFolderId); + return; + } + + if (target.id === 'upload-btn') { + this.dispatchEvent(new CustomEvent('upload-request', { + detail: { folderId: this.currentFolderId } + })); + return; + } + + if (target.id === 'create-folder-btn') { + this.handleCreateFolder(); + return; + } + + if (target.id === 'clear-selection-btn') { + this.clearSelection(); + return; + } + + if (target.id === 'batch-delete-btn') { + this.handleBatchAction('delete'); + return; + } + if (target.id === 'batch-move-btn') { + this.handleBatchAction('move'); + return; + } + if (target.id === 'batch-copy-btn') { + this.handleBatchAction('copy'); + return; + } + if (target.id === 'batch-star-btn') { + this.handleBatchAction('star'); + return; + } + if (target.id === 'batch-unstar-btn') { + this.handleBatchAction('unstar'); + return; + } + + if (target.classList.contains('action-btn')) { + e.stopPropagation(); + const action = target.dataset.action; + const id = parseInt(target.dataset.id); + this.handleAction(action, id); + return; + } + + if (target.classList.contains('select-item')) { + e.stopPropagation(); + return; + } + + const fileItem = target.closest('.file-item:not(.folder-item)'); + if (fileItem) { + const fileId = parseInt(fileItem.dataset.fileId); + const file = this.files.find(f => f.id === fileId); + + if (this.isEditableFile(file.name, file.mime_type)) { + this.dispatchEvent(new CustomEvent('edit-file', { + detail: { file: file }, + bubbles: true + })); + } else { + this.dispatchEvent(new CustomEvent('photo-click', { + detail: { photo: file }, + bubbles: true + })); + } + } + } + + handleDblClick(e) { + if (e.target.classList.contains('select-item') || + e.target.classList.contains('action-btn') || + e.target.classList.contains('breadcrumb-item')) { + return; + } + + const folderItem = e.target.closest('.folder-item'); + if (folderItem) { + const folderId = parseInt(folderItem.dataset.folderId); + this.loadContents(folderId); + } + } + + handleChange(e) { + const target = e.target; + + if (target.id === 'select-all') { + this.toggleSelectAll(target.checked); + return; + } + + if (target.classList.contains('select-item')) { + const type = target.dataset.type; + const id = parseInt(target.dataset.id); + this.toggleSelectItem(type, id, target.checked); + } + } + attachListeners() { - this.querySelector('#upload-btn')?.addEventListener('click', () => { - this.dispatchEvent(new CustomEvent('upload-request')); - }); - - this.querySelector('#create-folder-btn')?.addEventListener('click', async () => { - await this.handleCreateFolder(); - }); - - this.querySelectorAll('.folder-item').forEach(item => { - item.addEventListener('dblclick', () => { - const folderId = parseInt(item.dataset.folderId); - this.dispatchEvent(new CustomEvent('folder-open', { detail: { folderId } })); - }); - }); - - this.querySelectorAll('.file-item:not(.folder-item)').forEach(item => { - item.addEventListener('click', (e) => { - if (!e.target.classList.contains('action-btn') && !e.target.classList.contains('select-item')) { - const fileId = parseInt(item.dataset.fileId); - const file = this.files.find(f => f.id === fileId); - this.dispatchEvent(new CustomEvent('photo-click', { - detail: { photo: file }, - bubbles: true - })); - } - }); - }); - - this.querySelectorAll('.action-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const action = btn.dataset.action; - const id = parseInt(btn.dataset.id); - await this.handleAction(action, id); - }); - }); - - this.querySelectorAll('.select-item').forEach(checkbox => { - checkbox.addEventListener('change', (e) => { - const type = e.target.dataset.type; - const id = parseInt(e.target.dataset.id); - this.toggleSelectItem(type, id, e.target.checked); - }); - }); - - this.querySelector('#select-all')?.addEventListener('change', (e) => { - this.toggleSelectAll(e.target.checked); - }); - - this.querySelector('#batch-delete-btn')?.addEventListener('click', () => this.handleBatchAction('delete')); - this.querySelector('#batch-move-btn')?.addEventListener('click', () => this.handleBatchAction('move')); - this.querySelector('#batch-copy-btn')?.addEventListener('click', () => this.handleBatchAction('copy')); - this.querySelector('#batch-star-btn')?.addEventListener('click', () => this.handleBatchAction('star')); - this.querySelector('#batch-unstar-btn')?.addEventListener('click', () => this.handleBatchAction('unstar')); - this.updateBatchActionVisibility(); } @@ -204,7 +318,7 @@ export class FileList extends HTMLElement { this.selectedFolders.delete(id); } } - this.updateBatchActionVisibility(); + this.updateSelectionUI(); } toggleSelectAll(checked) { @@ -215,18 +329,75 @@ export class FileList extends HTMLElement { this.files.forEach(file => this.selectedFiles.add(file.id)); this.folders.forEach(folder => this.selectedFolders.add(folder.id)); } - this.render(); // Re-render to update checkboxes + + this.querySelectorAll('.select-item').forEach(checkbox => { + checkbox.checked = checked; + }); + + this.updateSelectionUI(); + } + + clearSelection() { + this.selectedFiles.clear(); + this.selectedFolders.clear(); + this.querySelectorAll('.select-item').forEach(checkbox => { + checkbox.checked = false; + }); + this.updateSelectionUI(); + } + + updateSelectionUI() { + const hasSelected = this.selectedFiles.size > 0 || this.selectedFolders.size > 0; + const totalItems = this.files.length + this.folders.length; + const totalSelected = this.selectedFiles.size + this.selectedFolders.size; + const allSelected = totalItems > 0 && totalSelected === totalItems; + + const selectAllCheckbox = this.querySelector('#select-all'); + const selectAllLabel = this.querySelector('label[for="select-all"]'); + const batchActionsDiv = this.querySelector('.batch-actions'); + + if (selectAllCheckbox) { + selectAllCheckbox.checked = allSelected; + this.updateIndeterminateState(); + } + + if (selectAllLabel) { + selectAllLabel.textContent = hasSelected ? `${totalSelected} selected` : 'Select all'; + } + + if (hasSelected && !batchActionsDiv) { + const container = this.querySelector('.file-list-container'); + const header = container.querySelector('.file-list-header'); + const batchBar = document.createElement('div'); + batchBar.className = 'batch-actions'; + batchBar.innerHTML = ` + + + + + + + `; + header.insertAdjacentElement('afterend', batchBar); + } else if (!hasSelected && batchActionsDiv) { + batchActionsDiv.remove(); + } + } + + updateIndeterminateState() { + const selectAllCheckbox = this.querySelector('#select-all'); + if (selectAllCheckbox) { + const totalItems = this.files.length + this.folders.length; + const totalSelected = this.selectedFiles.size + this.selectedFolders.size; + const hasSelected = totalSelected > 0; + const allSelected = totalItems > 0 && totalSelected === totalItems; + + selectAllCheckbox.indeterminate = hasSelected && !allSelected; + } } updateBatchActionVisibility() { - const batchActionsDiv = this.querySelector('.batch-actions'); - if (batchActionsDiv) { - if (this.selectedFiles.size > 0 || this.selectedFolders.size > 0) { - batchActionsDiv.style.display = 'flex'; - } else { - batchActionsDiv.style.display = 'none'; - } - } + this.updateSelectionUI(); } async handleBatchAction(action) { diff --git a/static/js/components/file-preview.js b/static/js/components/file-preview.js index 2ce985c..144be90 100644 --- a/static/js/components/file-preview.js +++ b/static/js/components/file-preview.js @@ -14,17 +14,20 @@ class FilePreview extends HTMLElement { setupEventListeners() { const closeBtn = this.querySelector('.close-preview'); - const modal = this.querySelector('.preview-modal'); const downloadBtn = this.querySelector('.download-btn'); const shareBtn = this.querySelector('.share-btn'); - closeBtn.addEventListener('click', () => this.close()); - modal.addEventListener('click', (e) => { - if (e.target === modal) this.close(); - }); + if (closeBtn) { + closeBtn.addEventListener('click', () => this.close()); + } - downloadBtn.addEventListener('click', () => this.downloadFile()); - shareBtn.addEventListener('click', () => this.shareFile()); + if (downloadBtn) { + downloadBtn.addEventListener('click', () => this.downloadFile()); + } + + if (shareBtn) { + shareBtn.addEventListener('click', () => this.shareFile()); + } } handleEscape(e) { @@ -33,17 +36,30 @@ class FilePreview extends HTMLElement { } } - async show(file) { + async show(file, pushState = true) { this.file = file; this.style.display = 'block'; document.addEventListener('keydown', this.handleEscape); this.renderPreview(); + + if (pushState) { + window.history.pushState( + { view: 'file-preview', file: file }, + '', + `#preview/${file.id}` + ); + } } close() { + window.history.back(); + } + + hide() { this.style.display = 'none'; this.file = null; document.removeEventListener('keydown', this.handleEscape); + this.remove(); } async renderPreview() { @@ -149,21 +165,21 @@ class FilePreview extends HTMLElement { render() { this.innerHTML = ` -
-
-
+
+
+
+
-

+

-
- - - -
-
+
+ + +
+
`; this.style.display = 'none'; diff --git a/static/js/components/file-upload-view.js b/static/js/components/file-upload-view.js new file mode 100644 index 0000000..42de0de --- /dev/null +++ b/static/js/components/file-upload-view.js @@ -0,0 +1,187 @@ +import { api } from '../api.js'; + +export class FileUploadView extends HTMLElement { + constructor() { + super(); + this.folderId = null; + this.handleEscape = this.handleEscape.bind(this); + } + + connectedCallback() { + document.addEventListener('keydown', this.handleEscape); + } + + disconnectedCallback() { + document.removeEventListener('keydown', this.handleEscape); + } + + setFolder(folderId) { + this.folderId = folderId; + this.render(); + this.attachListeners(); + this.openFileSelector(); + } + + render() { + const folderInfo = this.folderId ? `(Folder ID: ${this.folderId})` : '(Root)'; + this.innerHTML = ` + + `; + } + + openFileSelector() { + const fileInput = this.querySelector('#file-input'); + if (fileInput) { + fileInput.click(); + } + } + + attachListeners() { + const fileInput = this.querySelector('#file-input'); + const backBtn = this.querySelector('#upload-back-btn'); + + if (backBtn) { + backBtn.addEventListener('click', () => this.close()); + } + + if (fileInput) { + fileInput.addEventListener('change', (e) => { + if (e.target.files.length > 0) { + const view = this.querySelector('.file-upload-view'); + if (view) { + view.style.display = 'flex'; + } + this.handleFiles(e.target.files); + } else { + this.close(); + } + }); + + fileInput.addEventListener('cancel', () => { + this.close(); + }); + } + } + + handleEscape(e) { + if (e.key === 'Escape') { + this.close(); + } + } + + close() { + window.history.back(); + } + + hide() { + document.removeEventListener('keydown', this.handleEscape); + this.remove(); + } + + async handleFiles(files) { + const uploadList = this.querySelector('#upload-list'); + if (!uploadList) return; + + for (const file of files) { + const itemId = `upload-${Date.now()}-${Math.random()}`; + const item = document.createElement('div'); + item.className = 'upload-item'; + item.id = itemId; + item.innerHTML = ` +
+
${file.name}
+
${this.formatFileSize(file.size)}
+
+
+
+
+
+
Uploading...
+
+ `; + uploadList.appendChild(item); + + try { + await this.uploadFile(file, itemId); + const statusEl = item.querySelector('.upload-item-status'); + if (statusEl) { + statusEl.textContent = 'Complete'; + statusEl.classList.add('success'); + } + } catch (error) { + const statusEl = item.querySelector('.upload-item-status'); + if (statusEl) { + statusEl.textContent = 'Failed: ' + error.message; + statusEl.classList.add('error'); + } + } + } + + this.dispatchEvent(new CustomEvent('upload-complete', { bubbles: true })); + + setTimeout(() => { + this.close(); + }, 1500); + } + + async uploadFile(file, itemId) { + const formData = new FormData(); + formData.append('file', file); + if (this.folderId !== null && this.folderId !== undefined) { + formData.append('folder_id', String(this.folderId)); + } + + const xhr = new XMLHttpRequest(); + + return new Promise((resolve, reject) => { + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percentComplete = (e.loaded / e.total) * 100; + const item = this.querySelector(`#${itemId}`); + if (item) { + const progressFill = item.querySelector('.progress-fill'); + if (progressFill) { + progressFill.style.width = percentComplete + '%'; + } + } + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText)); + } else { + reject(new Error(xhr.statusText)); + } + }); + + xhr.addEventListener('error', () => reject(new Error('Upload failed'))); + xhr.addEventListener('abort', () => reject(new Error('Upload aborted'))); + + xhr.open('POST', '/files/upload'); + xhr.setRequestHeader('Authorization', `Bearer ${api.getToken()}`); + xhr.send(formData); + }); + } + + formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; + return (bytes / 1073741824).toFixed(1) + ' GB'; + } +} + +customElements.define('file-upload-view', FileUploadView); diff --git a/static/js/components/login-view.js b/static/js/components/login-view.js index e34952d..d54abbc 100644 --- a/static/js/components/login-view.js +++ b/static/js/components/login-view.js @@ -16,7 +16,7 @@ export class LoginView extends HTMLElement {
- +
diff --git a/static/js/components/photo-gallery.js b/static/js/components/photo-gallery.js index ac9466a..8d2a815 100644 --- a/static/js/components/photo-gallery.js +++ b/static/js/components/photo-gallery.js @@ -4,13 +4,19 @@ class PhotoGallery extends HTMLElement { constructor() { super(); this.photos = []; + this.boundHandleClick = this.handleClick.bind(this); } connectedCallback() { + this.addEventListener('click', this.boundHandleClick); this.render(); this.loadPhotos(); } + disconnectedCallback() { + this.removeEventListener('click', this.boundHandleClick); + } + async loadPhotos() { try { this.photos = await api.getPhotos(); @@ -62,16 +68,22 @@ class PhotoGallery extends HTMLElement { } }); - grid.querySelectorAll('.photo-item').forEach(item => { - item.addEventListener('click', () => { - const fileId = item.dataset.fileId; - const photo = this.photos.find(p => p.id === parseInt(fileId)); - this.dispatchEvent(new CustomEvent('photo-click', { - detail: { photo }, - bubbles: true - })); - }); - }); + this.attachListeners(); + } + + handleClick(e) { + const photoItem = e.target.closest('.photo-item'); + if (photoItem) { + const fileId = photoItem.dataset.fileId; + const photo = this.photos.find(p => p.id === parseInt(fileId)); + this.dispatchEvent(new CustomEvent('photo-click', { + detail: { photo }, + bubbles: true + })); + } + } + + attachListeners() { } render() { diff --git a/static/js/components/rbox-app.js b/static/js/components/rbox-app.js index c5680d5..fda6cbd 100644 --- a/static/js/components/rbox-app.js +++ b/static/js/components/rbox-app.js @@ -1,7 +1,7 @@ import { api } from '../api.js'; import './login-view.js'; import './file-list.js'; -import './file-upload.js'; +import './file-upload-view.js'; import './share-modal.js'; import './photo-gallery.js'; import './file-preview.js'; @@ -10,20 +10,24 @@ import './admin-dashboard.js'; import './toast-notification.js'; import './starred-items.js'; import './recent-files.js'; -import './shared-items.js'; // Import the new component +import './shared-items.js'; +import './billing-dashboard.js'; +import './admin-billing.js'; +import './code-editor-view.js'; import { shortcuts } from '../shortcuts.js'; export class RBoxApp extends HTMLElement { constructor() { super(); this.currentView = 'files'; - this.currentFolderId = null; this.user = null; + this.navigationStack = []; } async connectedCallback() { await this.init(); this.addEventListener('show-toast', this.handleShowToast); + window.addEventListener('popstate', this.handlePopState.bind(this)); } disconnectedCallback() { @@ -85,7 +89,9 @@ export class RBoxApp extends HTMLElement {
  • Photo Gallery
  • Shared Items
  • Deleted Files
  • +
  • Billing
  • ${this.user && this.user.is_superuser ? `
  • Admin Dashboard
  • ` : ''} + ${this.user && this.user.is_superuser ? `
  • Admin Billing
  • ` : ''}
    - -
    `; + this.initializeNavigation(); this.attachListeners(); this.registerShortcuts(); } + initializeNavigation() { + if (!window.history.state) { + const hash = window.location.hash.slice(1); + if (hash && hash !== '') { + const view = hash.split('/')[0]; + const validViews = ['files', 'photos', 'shared', 'deleted', 'starred', 'recent', 'admin', 'billing', 'admin-billing']; + if (validViews.includes(view)) { + window.history.replaceState({ view: view }, '', `#${hash}`); + this.currentView = view; + } else { + window.history.replaceState({ view: 'files' }, '', '#files'); + } + } else { + window.history.replaceState({ view: 'files' }, '', '#files'); + } + } + } + registerShortcuts() { shortcuts.register('ctrl+u', () => { - const upload = this.querySelector('file-upload'); - if (upload) { - upload.setFolder(this.currentFolderId); - upload.show(); - } + const fileList = this.querySelector('file-list'); + const folderId = fileList ? fileList.currentFolderId : null; + this.showUpload(folderId); }); shortcuts.register('ctrl+f', () => { @@ -164,7 +185,44 @@ export class RBoxApp extends HTMLElement { }); shortcuts.register('f2', () => { - console.log('Rename shortcut - to be implemented'); + const fileListComponent = document.querySelector('file-list'); + if (fileListComponent && fileListComponent.selectedFiles && fileListComponent.selectedFiles.size === 1) { + const fileId = Array.from(fileListComponent.selectedFiles)[0]; + const file = fileListComponent.files.find(f => f.id === fileId); + if (file) { + const newName = prompt('Enter new name:', file.name); + if (newName && newName !== file.name) { + api.renameFile(fileId, newName).then(() => { + fileListComponent.loadContents(fileListComponent.currentFolderId); + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'File renamed successfully', type: 'success' } + })); + }).catch(error => { + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Failed to rename file', type: 'error' } + })); + }); + } + } + } else if (fileListComponent && fileListComponent.selectedFolders && fileListComponent.selectedFolders.size === 1) { + const folderId = Array.from(fileListComponent.selectedFolders)[0]; + const folder = fileListComponent.folders.find(f => f.id === folderId); + if (folder) { + const newName = prompt('Enter new name:', folder.name); + if (newName && newName !== folder.name) { + api.updateFolder(folderId, { name: newName }).then(() => { + fileListComponent.loadContents(fileListComponent.currentFolderId); + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Folder renamed successfully', type: 'success' } + })); + }).catch(error => { + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Failed to rename folder', type: 'error' } + })); + }); + } + } + } }); } @@ -277,15 +335,12 @@ export class RBoxApp extends HTMLElement { const fileList = this.querySelector('file-list'); if (fileList) { - fileList.addEventListener('upload-request', () => { - const upload = this.querySelector('file-upload'); - upload.setFolder(this.currentFolderId); - upload.show(); + fileList.addEventListener('upload-request', (e) => { + this.showUpload(e.detail.folderId); }); fileList.addEventListener('folder-open', (e) => { - this.currentFolderId = e.detail.folderId; - fileList.loadContents(this.currentFolderId); + fileList.loadContents(e.detail.folderId); }); fileList.addEventListener('share-request', (e) => { @@ -294,13 +349,12 @@ export class RBoxApp extends HTMLElement { }); } - const upload = this.querySelector('file-upload'); - if (upload) { - upload.addEventListener('upload-complete', () => { - const fileList = this.querySelector('file-list'); - fileList.loadContents(this.currentFolderId); - }); - } + this.addEventListener('upload-complete', () => { + const fileList = this.querySelector('file-list'); + if (fileList) { + fileList.loadContents(fileList.currentFolderId); + } + }); const searchInput = this.querySelector('#search-input'); if (searchInput) { @@ -315,14 +369,109 @@ export class RBoxApp extends HTMLElement { } this.addEventListener('photo-click', (e) => { - const preview = this.querySelector('file-preview'); - preview.show(e.detail.photo); + this.showFilePreview(e.detail.photo); }); this.addEventListener('share-file', (e) => { const modal = this.querySelector('share-modal'); modal.show(e.detail.file.id); }); + + this.addEventListener('edit-file', (e) => { + this.showCodeEditor(e.detail.file); + }); + } + + handlePopState(e) { + this.closeAllOverlays(); + + if (e.state && e.state.view) { + if (e.state.view === 'code-editor' && e.state.file) { + this.showCodeEditor(e.state.file, false); + } else if (e.state.view === 'file-preview' && e.state.file) { + this.showFilePreview(e.state.file, false); + } else if (e.state.view === 'upload') { + const folderId = e.state.folderId !== undefined ? e.state.folderId : null; + this.showUpload(folderId, false); + } else { + this.switchView(e.state.view, false); + } + } else { + this.switchView('files', false); + } + } + + closeAllOverlays() { + const existingEditor = this.querySelector('code-editor-view'); + if (existingEditor) { + existingEditor.hide(); + } + + const existingPreview = this.querySelector('file-preview'); + if (existingPreview) { + existingPreview.hide(); + } + + const existingUpload = this.querySelector('file-upload-view'); + if (existingUpload) { + existingUpload.hide(); + } + + const shareModal = this.querySelector('share-modal'); + if (shareModal && shareModal.style.display !== 'none') { + shareModal.style.display = 'none'; + } + } + + showCodeEditor(file, pushState = true) { + this.closeAllOverlays(); + + const mainElement = this.querySelector('.app-main'); + const editorView = document.createElement('code-editor-view'); + mainElement.appendChild(editorView); + editorView.setFile(file, this.currentView); + + if (pushState) { + window.history.pushState( + { view: 'code-editor', file: file }, + '', + `#editor/${file.id}` + ); + } + } + + showFilePreview(file, pushState = true) { + this.closeAllOverlays(); + + const mainElement = this.querySelector('.app-main'); + const preview = document.createElement('file-preview'); + mainElement.appendChild(preview); + preview.show(file, false); + + if (pushState) { + window.history.pushState( + { view: 'file-preview', file: file }, + '', + `#preview/${file.id}` + ); + } + } + + showUpload(folderId = null, pushState = true) { + this.closeAllOverlays(); + + const mainElement = this.querySelector('.app-main'); + const uploadView = document.createElement('file-upload-view'); + mainElement.appendChild(uploadView); + uploadView.setFolder(folderId); + + if (pushState) { + window.history.pushState( + { view: 'upload', folderId: folderId }, + '', + '#upload' + ); + } } async performSearch(query) { @@ -343,7 +492,10 @@ export class RBoxApp extends HTMLElement { } } - switchView(view) { + switchView(view, pushState = true) { + this.closeAllOverlays(); + + if(this.currentView === view) return; this.currentView = view; this.querySelectorAll('.nav-link').forEach(link => { @@ -353,6 +505,10 @@ export class RBoxApp extends HTMLElement { const mainContent = this.querySelector('#main-content'); + if (pushState) { + window.history.pushState({ view: view }, '', `#${view}`); + } + switch (view) { case 'files': mainContent.innerHTML = ''; @@ -368,7 +524,7 @@ export class RBoxApp extends HTMLElement { break; case 'deleted': mainContent.innerHTML = ''; - this.attachListeners(); // Re-attach listeners for the new component + this.attachListeners(); break; case 'starred': mainContent.innerHTML = ''; @@ -382,6 +538,14 @@ export class RBoxApp extends HTMLElement { mainContent.innerHTML = ''; this.attachListeners(); break; + case 'billing': + mainContent.innerHTML = ''; + this.attachListeners(); + break; + case 'admin-billing': + mainContent.innerHTML = ''; + this.attachListeners(); + break; } } } diff --git a/static/js/components/recent-files.js b/static/js/components/recent-files.js index 18f08f7..a602d99 100644 --- a/static/js/components/recent-files.js +++ b/static/js/components/recent-files.js @@ -1,18 +1,22 @@ import { api } from '../api.js'; +import { BaseFileList } from './base-file-list.js'; -export class RecentFiles extends HTMLElement { +export class RecentFiles extends BaseFileList { constructor() { super(); - this.recentFiles = []; } async connectedCallback() { + super.connectedCallback(); await this.loadRecentFiles(); } async loadRecentFiles() { try { - this.recentFiles = await api.listRecentFiles(); + this.files = await api.listRecentFiles(); + this.folders = []; + this.selectedFiles.clear(); + this.selectedFolders.clear(); this.render(); } catch (error) { console.error('Failed to load recent files:', error); @@ -23,10 +27,17 @@ export class RecentFiles extends HTMLElement { } render() { - if (this.recentFiles.length === 0) { + const hasSelected = this.selectedFiles.size > 0; + const totalSelected = this.selectedFiles.size; + const allSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length; + const someSelected = hasSelected && !allSelected; + + if (this.files.length === 0) { this.innerHTML = ` -
    -

    Recent Files

    +
    +
    +

    Recent Files

    +

    No recent files found.

    `; @@ -34,23 +45,45 @@ export class RecentFiles extends HTMLElement { } this.innerHTML = ` -
    -

    Recent Files

    +
    +
    +
    +

    Recent Files

    + ${this.files.length > 0 ? ` +
    + + +
    + ` : ''} +
    +
    + + ${hasSelected ? ` +
    + +
    + ` : ''} +
    - ${this.recentFiles.map(file => this.renderFile(file)).join('')} + ${this.files.map(file => this.renderRecentFile(file)).join('')}
    `; this.attachListeners(); + this.updateIndeterminateState(); } - renderFile(file) { + renderRecentFile(file) { + const isSelected = this.selectedFiles.has(file.id); const icon = this.getFileIcon(file.mime_type); const size = this.formatFileSize(file.size); const lastAccessed = file.last_accessed_at ? new Date(file.last_accessed_at).toLocaleString() : 'N/A'; return ` -
    +
    +
    ${icon}
    ${file.name}
    ${size}
    @@ -62,51 +95,24 @@ export class RecentFiles extends HTMLElement { `; } - getFileIcon(mimeType) { - if (mimeType.startsWith('image/')) return '📷'; - if (mimeType.startsWith('video/')) return '🎥'; - if (mimeType.startsWith('audio/')) return '🎵'; - if (mimeType.includes('pdf')) return '📄'; - if (mimeType.includes('text')) return '📄'; - return '📄'; - } - - formatFileSize(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; - if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; - return (bytes / 1073741824).toFixed(1) + ' GB'; - } - - attachListeners() { - this.querySelectorAll('.action-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const action = btn.dataset.action; - const id = parseInt(btn.dataset.id); - if (action === 'download') { - await this.handleDownload(id); - } - }); - }); - } - - async handleDownload(fileId) { + async handleAction(action, id) { try { - const blob = await api.downloadFile(fileId); - const file = this.recentFiles.find(f => f.id === fileId); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = file.name; - a.click(); - URL.revokeObjectURL(url); - document.dispatchEvent(new CustomEvent('show-toast', { - detail: { message: 'File downloaded successfully!', type: 'success' } - })); + if (action === 'download') { + const blob = await api.downloadFile(id); + const file = this.files.find(f => f.id === id); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = file.name; + a.click(); + URL.revokeObjectURL(url); + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'File downloaded successfully!', type: 'success' } + })); + } } catch (error) { document.dispatchEvent(new CustomEvent('show-toast', { - detail: { message: 'Failed to download file: ' + error.message, type: 'error' } + detail: { message: 'Action failed: ' + error.message, type: 'error' } })); } } diff --git a/static/js/components/shared-items.js b/static/js/components/shared-items.js index 4e32d65..8cdb58f 100644 --- a/static/js/components/shared-items.js +++ b/static/js/components/shared-items.js @@ -4,12 +4,18 @@ export class SharedItems extends HTMLElement { constructor() { super(); this.myShares = []; + this.boundHandleClick = this.handleClick.bind(this); } async connectedCallback() { + this.addEventListener('click', this.boundHandleClick); await this.loadMyShares(); } + disconnectedCallback() { + this.removeEventListener('click', this.boundHandleClick); + } + async loadMyShares() { try { this.myShares = await api.listMyShares(); @@ -69,23 +75,25 @@ export class SharedItems extends HTMLElement { `; } - attachListeners() { - this.querySelectorAll('.share-actions .button').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const action = btn.dataset.action; - const id = parseInt(btn.dataset.id); - const link = btn.dataset.link; + handleClick(e) { + const target = e.target; + if (target.closest('.share-actions') && target.classList.contains('button')) { + e.stopPropagation(); + const action = target.dataset.action; + const id = parseInt(target.dataset.id); + const link = target.dataset.link; - if (action === 'copy-link') { - this.copyLink(link); - } else if (action === 'edit-share') { - this.handleEditShare(id); - } else if (action === 'delete-share') { - await this.handleDeleteShare(id); - } - }); - }); + if (action === 'copy-link') { + this.copyLink(link); + } else if (action === 'edit-share') { + this.handleEditShare(id); + } else if (action === 'delete-share') { + this.handleDeleteShare(id); + } + } + } + + attachListeners() { } copyLink(link) { diff --git a/static/js/components/starred-items.js b/static/js/components/starred-items.js index d40cad2..791da94 100644 --- a/static/js/components/starred-items.js +++ b/static/js/components/starred-items.js @@ -1,20 +1,22 @@ import { api } from '../api.js'; +import { BaseFileList } from './base-file-list.js'; -export class StarredItems extends HTMLElement { +export class StarredItems extends BaseFileList { constructor() { super(); - this.starredFiles = []; - this.starredFolders = []; } async connectedCallback() { + super.connectedCallback(); await this.loadStarredItems(); } async loadStarredItems() { try { - this.starredFiles = await api.listStarredFiles(); - this.starredFolders = await api.listStarredFolders(); + this.files = await api.listStarredFiles(); + this.folders = await api.listStarredFolders(); + this.selectedFiles.clear(); + this.selectedFolders.clear(); this.render(); } catch (error) { console.error('Failed to load starred items:', error); @@ -25,12 +27,21 @@ export class StarredItems extends HTMLElement { } render() { - const allStarred = [...this.starredFolders, ...this.starredFiles]; + const allStarred = [...this.folders, ...this.files]; + const hasSelected = this.selectedFiles.size > 0 || this.selectedFolders.size > 0; + const totalItems = this.files.length + this.folders.length; + const totalSelected = this.selectedFiles.size + this.selectedFolders.size; + const allFilesSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length; + const allFoldersSelected = this.folders.length > 0 && this.selectedFolders.size === this.folders.length; + const allSelected = totalItems > 0 && allFilesSelected && allFoldersSelected; + const someSelected = hasSelected && !allSelected; if (allStarred.length === 0) { this.innerHTML = ` -
    -

    Starred Items

    +
    +
    +

    Starred Items

    +

    No starred items found.

    `; @@ -38,71 +49,90 @@ export class StarredItems extends HTMLElement { } this.innerHTML = ` -
    -

    Starred Items

    +
    +
    +
    +

    Starred Items

    + ${totalItems > 0 ? ` +
    + + +
    + ` : ''} +
    +
    + + ${hasSelected ? ` +
    + + +
    + ` : ''} +
    - ${this.starredFolders.map(folder => this.renderFolder(folder)).join('')} - ${this.starredFiles.map(file => this.renderFile(file)).join('')} + ${this.folders.map(folder => this.renderFolder(folder)).join('')} + ${this.files.map(file => this.renderFile(file)).join('')}
    `; this.attachListeners(); + this.updateIndeterminateState(); } - renderFolder(folder) { + getFolderActions(folder) { + return ``; + } + + getFileActions(file) { return ` -
    -
    📁
    -
    ${folder.name}
    -
    - -
    -
    + + `; } - renderFile(file) { - const icon = this.getFileIcon(file.mime_type); - const size = this.formatFileSize(file.size); - - return ` -
    -
    ${icon}
    -
    ${file.name}
    -
    ${size}
    -
    - - -
    -
    + createBatchActionsBar() { + const container = this.querySelector('.file-list-container'); + const header = container.querySelector('.file-list-header'); + const batchBar = document.createElement('div'); + batchBar.className = 'batch-actions'; + batchBar.innerHTML = ` + + `; - } - - getFileIcon(mimeType) { - if (mimeType.startsWith('image/')) return '📷'; - if (mimeType.startsWith('video/')) return '🎥'; - if (mimeType.startsWith('audio/')) return '🎵'; - if (mimeType.includes('pdf')) return '📄'; - if (mimeType.includes('text')) return '📄'; - return '📄'; - } - - formatFileSize(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; - if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; - return (bytes / 1073741824).toFixed(1) + ' GB'; + header.insertAdjacentElement('afterend', batchBar); } attachListeners() { - this.querySelectorAll('.action-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const action = btn.dataset.action; - const id = parseInt(btn.dataset.id); - await this.handleAction(action, id); - }); - }); + const batchUnstarBtn = this.querySelector('#batch-unstar-btn'); + if (batchUnstarBtn) { + batchUnstarBtn.addEventListener('click', () => this.handleBatchUnstar()); + } + } + + async handleBatchUnstar() { + const totalSelected = this.selectedFiles.size + this.selectedFolders.size; + if (totalSelected === 0) return; + + if (!confirm(`Unstar ${totalSelected} items?`)) return; + + try { + for (const fileId of this.selectedFiles) { + await api.unstarFile(fileId); + } + for (const folderId of this.selectedFolders) { + await api.unstarFolder(folderId); + } + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Items unstarred successfully!', type: 'success' } + })); + await this.loadStarredItems(); + } catch (error) { + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Failed to unstar items: ' + error.message, type: 'error' } + })); + } } async handleAction(action, id) { @@ -110,7 +140,7 @@ export class StarredItems extends HTMLElement { switch (action) { case 'download': const blob = await api.downloadFile(id); - const file = this.starredFiles.find(f => f.id === id); + const file = this.files.find(f => f.id === id); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; diff --git a/static/js/main.js b/static/js/main.js index b73a3a5..703e106 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -12,12 +12,6 @@ const app = new RBoxApp(); // Register service worker for PWA if ('serviceWorker' in navigator) { window.addEventListener('load', () => { - navigator.serviceWorker.register('/static/service-worker.js') - .then(registration => { - console.log('Service Worker registered with scope:', registration.scope); - }) - .catch(error => { - console.log('Service Worker registration failed:', error); - }); + navigator.serviceWorker.register('/static/service-worker.js'); }); } diff --git a/static/service-worker.js b/static/service-worker.js index 6e11032..40f542b 100644 --- a/static/service-worker.js +++ b/static/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'rbox-cache-v1'; +const CACHE_NAME = 'rbox-cache-v11'; const urlsToCache = [ '/', '/static/index.html', diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/billing/__init__.py b/tests/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/billing/test_api_endpoints.py b/tests/billing/test_api_endpoints.py new file mode 100644 index 0000000..a10ed16 --- /dev/null +++ b/tests/billing/test_api_endpoints.py @@ -0,0 +1,256 @@ +import pytest +from decimal import Decimal +from datetime import date, datetime +from httpx import AsyncClient +from fastapi import status +from tortoise.contrib.test import initializer, finalizer +from rbox.main import app +from rbox.models import User +from rbox.billing.models import PricingConfig, Invoice, UsageAggregate, UserSubscription +from rbox.auth import create_access_token + +@pytest.fixture(scope="module") +def event_loop(): + import asyncio + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope="module", autouse=True) +async def initialize_tests(): + initializer(["rbox.models", "rbox.billing.models"], db_url="sqlite://:memory:") + yield + await finalizer() + +@pytest.fixture +async def test_user(): + user = await User.create( + username="testuser", + email="test@example.com", + hashed_password="hashed_password_here", + is_active=True, + is_superuser=False + ) + yield user + await user.delete() + +@pytest.fixture +async def admin_user(): + user = await User.create( + username="adminuser", + email="admin@example.com", + hashed_password="hashed_password_here", + is_active=True, + is_superuser=True + ) + yield user + await user.delete() + +@pytest.fixture +async def auth_token(test_user): + token = create_access_token(data={"sub": test_user.username}) + return token + +@pytest.fixture +async def admin_token(admin_user): + token = create_access_token(data={"sub": admin_user.username}) + return token + +@pytest.fixture +async def pricing_config(): + configs = [] + configs.append(await PricingConfig.create( + config_key="storage_per_gb_month", + config_value=Decimal("0.0045"), + description="Storage cost per GB per month", + unit="per_gb_month" + )) + configs.append(await PricingConfig.create( + config_key="bandwidth_egress_per_gb", + config_value=Decimal("0.009"), + description="Bandwidth egress cost per GB", + unit="per_gb" + )) + configs.append(await PricingConfig.create( + config_key="free_tier_storage_gb", + config_value=Decimal("15"), + description="Free tier storage in GB", + unit="gb" + )) + configs.append(await PricingConfig.create( + config_key="free_tier_bandwidth_gb", + config_value=Decimal("15"), + description="Free tier bandwidth in GB per month", + unit="gb" + )) + yield configs + for config in configs: + await config.delete() + +@pytest.mark.asyncio +async def test_get_current_usage(test_user, auth_token): + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get( + "/api/billing/usage/current", + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert "storage_gb" in data + assert "bandwidth_down_gb_today" in data + +@pytest.mark.asyncio +async def test_get_monthly_usage(test_user, auth_token): + today = date.today() + + await UsageAggregate.create( + user=test_user, + date=today, + storage_bytes_avg=1024 ** 3 * 10, + storage_bytes_peak=1024 ** 3 * 12, + bandwidth_up_bytes=1024 ** 3 * 2, + bandwidth_down_bytes=1024 ** 3 * 5 + ) + + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get( + f"/api/billing/usage/monthly?year={today.year}&month={today.month}", + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["storage_gb_avg"] == pytest.approx(10.0, rel=0.01) + + await UsageAggregate.filter(user=test_user).delete() + +@pytest.mark.asyncio +async def test_get_subscription(test_user, auth_token): + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get( + "/api/billing/subscription", + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["billing_type"] == "pay_as_you_go" + assert data["status"] == "active" + + await UserSubscription.filter(user=test_user).delete() + +@pytest.mark.asyncio +async def test_list_invoices(test_user, auth_token): + invoice = await Invoice.create( + user=test_user, + invoice_number="INV-000001-202311", + period_start=date(2023, 11, 1), + period_end=date(2023, 11, 30), + subtotal=Decimal("10.00"), + tax=Decimal("0.00"), + total=Decimal("10.00"), + status="open" + ) + + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get( + "/api/billing/invoices", + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) > 0 + assert data[0]["invoice_number"] == "INV-000001-202311" + + await invoice.delete() + +@pytest.mark.asyncio +async def test_get_invoice(test_user, auth_token): + invoice = await Invoice.create( + user=test_user, + invoice_number="INV-000002-202311", + period_start=date(2023, 11, 1), + period_end=date(2023, 11, 30), + subtotal=Decimal("10.00"), + tax=Decimal("0.00"), + total=Decimal("10.00"), + status="open" + ) + + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get( + f"/api/billing/invoices/{invoice.id}", + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["invoice_number"] == "INV-000002-202311" + + await invoice.delete() + +@pytest.mark.asyncio +async def test_get_pricing(): + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get("/api/billing/pricing") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert isinstance(data, dict) + +@pytest.mark.asyncio +async def test_admin_get_pricing(admin_user, admin_token, pricing_config): + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get( + "/api/admin/billing/pricing", + headers={"Authorization": f"Bearer {admin_token}"} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) > 0 + +@pytest.mark.asyncio +async def test_admin_update_pricing(admin_user, admin_token, pricing_config): + config_id = pricing_config[0].id + + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.put( + f"/api/admin/billing/pricing/{config_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "config_key": "storage_per_gb_month", + "config_value": 0.005 + } + ) + + assert response.status_code == status.HTTP_200_OK + + updated = await PricingConfig.get(id=config_id) + assert updated.config_value == Decimal("0.005") + +@pytest.mark.asyncio +async def test_admin_get_stats(admin_user, admin_token): + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get( + "/api/admin/billing/stats", + headers={"Authorization": f"Bearer {admin_token}"} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert "total_revenue" in data + assert "total_invoices" in data + assert "pending_invoices" in data + +@pytest.mark.asyncio +async def test_non_admin_cannot_access_admin_endpoints(test_user, auth_token): + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get( + "/api/admin/billing/pricing", + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/tests/billing/test_invoice_generator.py b/tests/billing/test_invoice_generator.py new file mode 100644 index 0000000..d844c41 --- /dev/null +++ b/tests/billing/test_invoice_generator.py @@ -0,0 +1,195 @@ +import pytest +from decimal import Decimal +from datetime import date, datetime +from tortoise.contrib.test import initializer, finalizer +from rbox.models import User +from rbox.billing.models import Invoice, InvoiceLineItem, PricingConfig, UsageAggregate, UserSubscription +from rbox.billing.invoice_generator import InvoiceGenerator + +@pytest.fixture(scope="module") +def event_loop(): + import asyncio + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope="module", autouse=True) +async def initialize_tests(): + initializer(["rbox.models", "rbox.billing.models"], db_url="sqlite://:memory:") + yield + await finalizer() + +@pytest.fixture +async def test_user(): + user = await User.create( + username="testuser", + email="test@example.com", + hashed_password="hashed_password_here", + is_active=True + ) + yield user + await user.delete() + +@pytest.fixture +async def pricing_config(): + configs = [] + configs.append(await PricingConfig.create( + config_key="storage_per_gb_month", + config_value=Decimal("0.0045"), + description="Storage cost per GB per month", + unit="per_gb_month" + )) + configs.append(await PricingConfig.create( + config_key="bandwidth_egress_per_gb", + config_value=Decimal("0.009"), + description="Bandwidth egress cost per GB", + unit="per_gb" + )) + configs.append(await PricingConfig.create( + config_key="free_tier_storage_gb", + config_value=Decimal("15"), + description="Free tier storage in GB", + unit="gb" + )) + configs.append(await PricingConfig.create( + config_key="free_tier_bandwidth_gb", + config_value=Decimal("15"), + description="Free tier bandwidth in GB per month", + unit="gb" + )) + configs.append(await PricingConfig.create( + config_key="tax_rate_default", + config_value=Decimal("0.0"), + description="Default tax rate", + unit="percentage" + )) + yield configs + for config in configs: + await config.delete() + +@pytest.mark.asyncio +async def test_generate_monthly_invoice_with_usage(test_user, pricing_config): + today = date.today() + + await UsageAggregate.create( + user=test_user, + date=today, + storage_bytes_avg=1024 ** 3 * 50, + storage_bytes_peak=1024 ** 3 * 55, + bandwidth_up_bytes=1024 ** 3 * 10, + bandwidth_down_bytes=1024 ** 3 * 20 + ) + + invoice = await InvoiceGenerator.generate_monthly_invoice(test_user, today.year, today.month) + + assert invoice is not None + assert invoice.user_id == test_user.id + assert invoice.status == "draft" + assert invoice.total > 0 + + line_items = await invoice.line_items.all() + assert len(line_items) > 0 + + await invoice.delete() + await UsageAggregate.filter(user=test_user).delete() + +@pytest.mark.asyncio +async def test_generate_monthly_invoice_below_free_tier(test_user, pricing_config): + today = date.today() + + await UsageAggregate.create( + user=test_user, + date=today, + storage_bytes_avg=1024 ** 3 * 10, + storage_bytes_peak=1024 ** 3 * 12, + bandwidth_up_bytes=1024 ** 3 * 5, + bandwidth_down_bytes=1024 ** 3 * 10 + ) + + invoice = await InvoiceGenerator.generate_monthly_invoice(test_user, today.year, today.month) + + assert invoice is None + + await UsageAggregate.filter(user=test_user).delete() + +@pytest.mark.asyncio +async def test_finalize_invoice(test_user, pricing_config): + invoice = await Invoice.create( + user=test_user, + invoice_number="INV-000001-202311", + period_start=date(2023, 11, 1), + period_end=date(2023, 11, 30), + subtotal=Decimal("10.00"), + tax=Decimal("0.00"), + total=Decimal("10.00"), + status="draft" + ) + + finalized = await InvoiceGenerator.finalize_invoice(invoice) + + assert finalized.status == "open" + + await finalized.delete() + +@pytest.mark.asyncio +async def test_finalize_invoice_already_finalized(test_user, pricing_config): + invoice = await Invoice.create( + user=test_user, + invoice_number="INV-000002-202311", + period_start=date(2023, 11, 1), + period_end=date(2023, 11, 30), + subtotal=Decimal("10.00"), + tax=Decimal("0.00"), + total=Decimal("10.00"), + status="open" + ) + + with pytest.raises(ValueError): + await InvoiceGenerator.finalize_invoice(invoice) + + await invoice.delete() + +@pytest.mark.asyncio +async def test_mark_invoice_paid(test_user, pricing_config): + invoice = await Invoice.create( + user=test_user, + invoice_number="INV-000003-202311", + period_start=date(2023, 11, 1), + period_end=date(2023, 11, 30), + subtotal=Decimal("10.00"), + tax=Decimal("0.00"), + total=Decimal("10.00"), + status="open" + ) + + paid = await InvoiceGenerator.mark_invoice_paid(invoice) + + assert paid.status == "paid" + assert paid.paid_at is not None + + await paid.delete() + +@pytest.mark.asyncio +async def test_invoice_with_tax(test_user): + await PricingConfig.filter(config_key="tax_rate_default").update(config_value=Decimal("0.21")) + + today = date.today() + + await UsageAggregate.create( + user=test_user, + date=today, + storage_bytes_avg=1024 ** 3 * 50, + storage_bytes_peak=1024 ** 3 * 55, + bandwidth_up_bytes=1024 ** 3 * 10, + bandwidth_down_bytes=1024 ** 3 * 20 + ) + + invoice = await InvoiceGenerator.generate_monthly_invoice(test_user, today.year, today.month) + + assert invoice is not None + assert invoice.tax > 0 + assert invoice.total == invoice.subtotal + invoice.tax + + await invoice.delete() + await UsageAggregate.filter(user=test_user).delete() + await PricingConfig.filter(config_key="tax_rate_default").update(config_value=Decimal("0.0")) diff --git a/tests/billing/test_models.py b/tests/billing/test_models.py new file mode 100644 index 0000000..6481c03 --- /dev/null +++ b/tests/billing/test_models.py @@ -0,0 +1,188 @@ +import pytest +import pytest_asyncio +from decimal import Decimal +from datetime import date, datetime +from tortoise.contrib.test import initializer, finalizer +from rbox.models import User +from rbox.billing.models import ( + SubscriptionPlan, UserSubscription, UsageRecord, UsageAggregate, + Invoice, InvoiceLineItem, PricingConfig, PaymentMethod, BillingEvent +) + +@pytest.fixture(scope="module") +def event_loop(): + import asyncio + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest_asyncio.fixture(scope="module", autouse=True) +async def initialize_tests(): + initializer(["rbox.models", "rbox.billing.models"], db_url="sqlite://:memory:") + yield + await finalizer() + +@pytest_asyncio.fixture +async def test_user(): + user = await User.create( + username="testuser", + email="test@example.com", + hashed_password="hashed_password_here", + is_active=True + ) + yield user + await user.delete() + +@pytest.mark.asyncio +async def test_subscription_plan_creation(): + plan = await SubscriptionPlan.create( + name="starter", + display_name="Starter Plan", + description="Basic storage plan", + storage_gb=100, + bandwidth_gb=100, + price_monthly=Decimal("5.00"), + price_yearly=Decimal("50.00") + ) + + assert plan.name == "starter" + assert plan.storage_gb == 100 + assert plan.price_monthly == Decimal("5.00") + await plan.delete() + +@pytest.mark.asyncio +async def test_user_subscription_creation(test_user): + subscription = await UserSubscription.create( + user=test_user, + billing_type="pay_as_you_go", + status="active" + ) + + assert subscription.user_id == test_user.id + assert subscription.billing_type == "pay_as_you_go" + assert subscription.status == "active" + await subscription.delete() + +@pytest.mark.asyncio +async def test_usage_record_creation(test_user): + usage = await UsageRecord.create( + user=test_user, + record_type="storage", + amount_bytes=1024 * 1024 * 100, + resource_type="file", + resource_id=1, + idempotency_key="test_key_123" + ) + + assert usage.user_id == test_user.id + assert usage.record_type == "storage" + assert usage.amount_bytes == 1024 * 1024 * 100 + await usage.delete() + +@pytest.mark.asyncio +async def test_usage_aggregate_creation(test_user): + aggregate = await UsageAggregate.create( + user=test_user, + date=date.today(), + storage_bytes_avg=1024 * 1024 * 500, + storage_bytes_peak=1024 * 1024 * 600, + bandwidth_up_bytes=1024 * 1024 * 50, + bandwidth_down_bytes=1024 * 1024 * 100 + ) + + assert aggregate.user_id == test_user.id + assert aggregate.storage_bytes_avg == 1024 * 1024 * 500 + await aggregate.delete() + +@pytest.mark.asyncio +async def test_invoice_creation(test_user): + invoice = await Invoice.create( + user=test_user, + invoice_number="INV-000001-202311", + period_start=date(2023, 11, 1), + period_end=date(2023, 11, 30), + subtotal=Decimal("10.00"), + tax=Decimal("0.00"), + total=Decimal("10.00"), + status="draft" + ) + + assert invoice.user_id == test_user.id + assert invoice.invoice_number == "INV-000001-202311" + assert invoice.total == Decimal("10.00") + await invoice.delete() + +@pytest.mark.asyncio +async def test_invoice_line_item_creation(test_user): + invoice = await Invoice.create( + user=test_user, + invoice_number="INV-000002-202311", + period_start=date(2023, 11, 1), + period_end=date(2023, 11, 30), + subtotal=Decimal("10.00"), + tax=Decimal("0.00"), + total=Decimal("10.00"), + status="draft" + ) + + line_item = await InvoiceLineItem.create( + invoice=invoice, + description="Storage usage", + quantity=Decimal("100.000000"), + unit_price=Decimal("0.100000"), + amount=Decimal("10.0000"), + item_type="storage" + ) + + assert line_item.invoice_id == invoice.id + assert line_item.description == "Storage usage" + assert line_item.amount == Decimal("10.0000") + await line_item.delete() + await invoice.delete() + +@pytest.mark.asyncio +async def test_pricing_config_creation(test_user): + config = await PricingConfig.create( + config_key="storage_per_gb_month", + config_value=Decimal("0.0045"), + description="Storage cost per GB per month", + unit="per_gb_month", + updated_by=test_user + ) + + assert config.config_key == "storage_per_gb_month" + assert config.config_value == Decimal("0.0045") + await config.delete() + +@pytest.mark.asyncio +async def test_payment_method_creation(test_user): + payment_method = await PaymentMethod.create( + user=test_user, + stripe_payment_method_id="pm_test_123", + type="card", + is_default=True, + last4="4242", + brand="visa", + exp_month=12, + exp_year=2025 + ) + + assert payment_method.user_id == test_user.id + assert payment_method.last4 == "4242" + assert payment_method.is_default is True + await payment_method.delete() + +@pytest.mark.asyncio +async def test_billing_event_creation(test_user): + event = await BillingEvent.create( + user=test_user, + event_type="invoice_created", + stripe_event_id="evt_test_123", + data={"invoice_id": 1}, + processed=False + ) + + assert event.user_id == test_user.id + assert event.event_type == "invoice_created" + assert event.processed is False + await event.delete() diff --git a/tests/billing/test_simple.py b/tests/billing/test_simple.py new file mode 100644 index 0000000..2dda104 --- /dev/null +++ b/tests/billing/test_simple.py @@ -0,0 +1,77 @@ +import pytest + +def test_billing_module_imports(): + from rbox.billing import models, stripe_client, usage_tracker, invoice_generator, scheduler + assert models is not None + assert stripe_client is not None + assert usage_tracker is not None + assert invoice_generator is not None + assert scheduler is not None + +def test_billing_models_exist(): + from rbox.billing.models import ( + SubscriptionPlan, UserSubscription, UsageRecord, UsageAggregate, + Invoice, InvoiceLineItem, PricingConfig, PaymentMethod, BillingEvent + ) + assert SubscriptionPlan is not None + assert UserSubscription is not None + assert UsageRecord is not None + assert UsageAggregate is not None + assert Invoice is not None + assert InvoiceLineItem is not None + assert PricingConfig is not None + assert PaymentMethod is not None + assert BillingEvent is not None + +def test_stripe_client_exists(): + from rbox.billing.stripe_client import StripeClient + assert StripeClient is not None + assert hasattr(StripeClient, 'create_customer') + assert hasattr(StripeClient, 'create_invoice') + assert hasattr(StripeClient, 'finalize_invoice') + +def test_usage_tracker_exists(): + from rbox.billing.usage_tracker import UsageTracker + assert UsageTracker is not None + assert hasattr(UsageTracker, 'track_storage') + assert hasattr(UsageTracker, 'track_bandwidth') + assert hasattr(UsageTracker, 'aggregate_daily_usage') + assert hasattr(UsageTracker, 'get_current_storage') + assert hasattr(UsageTracker, 'get_monthly_usage') + +def test_invoice_generator_exists(): + from rbox.billing.invoice_generator import InvoiceGenerator + assert InvoiceGenerator is not None + assert hasattr(InvoiceGenerator, 'generate_monthly_invoice') + assert hasattr(InvoiceGenerator, 'finalize_invoice') + assert hasattr(InvoiceGenerator, 'mark_invoice_paid') + +def test_scheduler_exists(): + from rbox.billing.scheduler import scheduler, start_scheduler, stop_scheduler + assert scheduler is not None + assert callable(start_scheduler) + assert callable(stop_scheduler) + +def test_routers_exist(): + from rbox.routers import billing, admin_billing + assert billing is not None + assert admin_billing is not None + assert hasattr(billing, 'router') + assert hasattr(admin_billing, 'router') + +def test_middleware_exists(): + from rbox.middleware.usage_tracking import UsageTrackingMiddleware + assert UsageTrackingMiddleware is not None + +def test_settings_updated(): + from rbox.settings import settings + assert hasattr(settings, 'STRIPE_SECRET_KEY') + assert hasattr(settings, 'STRIPE_PUBLISHABLE_KEY') + assert hasattr(settings, 'STRIPE_WEBHOOK_SECRET') + assert hasattr(settings, 'BILLING_ENABLED') + +def test_main_includes_billing(): + from rbox.main import app + routes = [route.path for route in app.routes] + billing_routes = [r for r in routes if '/billing' in r] + assert len(billing_routes) > 0 diff --git a/tests/billing/test_usage_tracker.py b/tests/billing/test_usage_tracker.py new file mode 100644 index 0000000..d316a73 --- /dev/null +++ b/tests/billing/test_usage_tracker.py @@ -0,0 +1,175 @@ +import pytest +from decimal import Decimal +from datetime import date, datetime, timedelta +from tortoise.contrib.test import initializer, finalizer +from rbox.models import User, File, Folder +from rbox.billing.models import UsageRecord, UsageAggregate +from rbox.billing.usage_tracker import UsageTracker + +@pytest.fixture(scope="module") +def event_loop(): + import asyncio + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope="module", autouse=True) +async def initialize_tests(): + initializer(["rbox.models", "rbox.billing.models"], db_url="sqlite://:memory:") + yield + await finalizer() + +@pytest.fixture +async def test_user(): + user = await User.create( + username="testuser", + email="test@example.com", + hashed_password="hashed_password_here", + is_active=True + ) + yield user + await user.delete() + +@pytest.mark.asyncio +async def test_track_storage(test_user): + await UsageTracker.track_storage( + user=test_user, + amount_bytes=1024 * 1024 * 100, + resource_type="file", + resource_id=1 + ) + + records = await UsageRecord.filter(user=test_user, record_type="storage").all() + assert len(records) == 1 + assert records[0].amount_bytes == 1024 * 1024 * 100 + + await records[0].delete() + +@pytest.mark.asyncio +async def test_track_bandwidth_upload(test_user): + await UsageTracker.track_bandwidth( + user=test_user, + amount_bytes=1024 * 1024 * 50, + direction="up", + resource_type="file", + resource_id=1 + ) + + records = await UsageRecord.filter(user=test_user, record_type="bandwidth_up").all() + assert len(records) == 1 + assert records[0].amount_bytes == 1024 * 1024 * 50 + + await records[0].delete() + +@pytest.mark.asyncio +async def test_track_bandwidth_download(test_user): + await UsageTracker.track_bandwidth( + user=test_user, + amount_bytes=1024 * 1024 * 75, + direction="down", + resource_type="file", + resource_id=1 + ) + + records = await UsageRecord.filter(user=test_user, record_type="bandwidth_down").all() + assert len(records) == 1 + assert records[0].amount_bytes == 1024 * 1024 * 75 + + await records[0].delete() + +@pytest.mark.asyncio +async def test_aggregate_daily_usage(test_user): + await UsageTracker.track_storage( + user=test_user, + amount_bytes=1024 * 1024 * 100 + ) + + await UsageTracker.track_bandwidth( + user=test_user, + amount_bytes=1024 * 1024 * 50, + direction="up" + ) + + await UsageTracker.track_bandwidth( + user=test_user, + amount_bytes=1024 * 1024 * 75, + direction="down" + ) + + aggregate = await UsageTracker.aggregate_daily_usage(test_user, date.today()) + + assert aggregate is not None + assert aggregate.storage_bytes_avg > 0 + assert aggregate.bandwidth_up_bytes == 1024 * 1024 * 50 + assert aggregate.bandwidth_down_bytes == 1024 * 1024 * 75 + + await aggregate.delete() + await UsageRecord.filter(user=test_user).delete() + +@pytest.mark.asyncio +async def test_get_current_storage(test_user): + folder = await Folder.create( + name="Test Folder", + owner=test_user + ) + + file1 = await File.create( + name="test1.txt", + path="/storage/test1.txt", + size=1024 * 1024 * 10, + mime_type="text/plain", + owner=test_user, + parent=folder, + is_deleted=False + ) + + file2 = await File.create( + name="test2.txt", + path="/storage/test2.txt", + size=1024 * 1024 * 20, + mime_type="text/plain", + owner=test_user, + parent=folder, + is_deleted=False + ) + + storage = await UsageTracker.get_current_storage(test_user) + + assert storage == 1024 * 1024 * 30 + + await file1.delete() + await file2.delete() + await folder.delete() + +@pytest.mark.asyncio +async def test_get_monthly_usage(test_user): + today = date.today() + + aggregate = await UsageAggregate.create( + user=test_user, + date=today, + storage_bytes_avg=1024 * 1024 * 1024 * 10, + storage_bytes_peak=1024 * 1024 * 1024 * 12, + bandwidth_up_bytes=1024 * 1024 * 1024 * 2, + bandwidth_down_bytes=1024 * 1024 * 1024 * 5 + ) + + usage = await UsageTracker.get_monthly_usage(test_user, today.year, today.month) + + assert usage["storage_gb_avg"] == pytest.approx(10.0, rel=0.01) + assert usage["storage_gb_peak"] == pytest.approx(12.0, rel=0.01) + assert usage["bandwidth_up_gb"] == pytest.approx(2.0, rel=0.01) + assert usage["bandwidth_down_gb"] == pytest.approx(5.0, rel=0.01) + + await aggregate.delete() + +@pytest.mark.asyncio +async def test_get_monthly_usage_empty(test_user): + future_date = date.today() + timedelta(days=365) + + usage = await UsageTracker.get_monthly_usage(test_user, future_date.year, future_date.month) + + assert usage["storage_gb_avg"] == 0 + assert usage["storage_gb_peak"] == 0 + assert usage["bandwidth_up_gb"] == 0 + assert usage["bandwidth_down_gb"] == 0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d1f671 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest +import asyncio + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..1b32b11 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,38 @@ +import pytest +import pytest_asyncio +import asyncio +from playwright.async_api import async_playwright + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest_asyncio.fixture(scope="function") +async def browser(): + async with async_playwright() as p: + browser = await p.chromium.launch(headless=False, slow_mo=500) + yield browser + await browser.close() + +@pytest_asyncio.fixture(scope="function") +async def context(browser): + context = await browser.new_context( + viewport={"width": 1920, "height": 1080}, + user_agent="Mozilla/5.0 (X11; Linux x86_64) RBox E2E Tests", + ignore_https_errors=True, + service_workers='block' + ) + yield context + await context.close() + +@pytest_asyncio.fixture(scope="function") +async def page(context): + page = await context.new_page() + yield page + await page.close() + +@pytest.fixture +def base_url(): + return "http://localhost:8000" diff --git a/tests/e2e/test_billing_admin_flow.py b/tests/e2e/test_billing_admin_flow.py new file mode 100644 index 0000000..3cfa59f --- /dev/null +++ b/tests/e2e/test_billing_admin_flow.py @@ -0,0 +1,199 @@ +import pytest +import asyncio +from playwright.async_api import expect + +@pytest.mark.asyncio +class TestBillingAdminFlow: + + async def test_01_admin_login(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('input[name="username"]', 'adminuser') + await page.fill('input[name="password"]', 'adminpassword123') + await page.click('button[type="submit"]') + + await page.wait_for_load_state("networkidle") + await expect(page.locator('text=Dashboard')).to_be_visible() + + async def test_02_navigate_to_admin_billing(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('input[name="username"]', 'adminuser') + await page.fill('input[name="password"]', 'adminpassword123') + await page.click('button[type="submit"]') + await page.wait_for_load_state("networkidle") + + await page.click('text=Admin') + await page.wait_for_load_state("networkidle") + + await page.click('text=Billing') + await page.wait_for_url("**/admin/billing") + + await expect(page.locator('h2:has-text("Billing Administration")')).to_be_visible() + + async def test_03_view_revenue_statistics(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.stats-cards')).to_be_visible() + await expect(page.locator('.stat-card:has-text("Total Revenue")')).to_be_visible() + await expect(page.locator('.stat-card:has-text("Total Invoices")')).to_be_visible() + await expect(page.locator('.stat-card:has-text("Pending Invoices")')).to_be_visible() + + async def test_04_verify_revenue_value_display(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + revenue_stat = page.locator('.stat-card:has-text("Total Revenue") .stat-value') + await expect(revenue_stat).to_be_visible() + + revenue_text = await revenue_stat.text_content() + assert '$' in revenue_text or '0' in revenue_text + + async def test_05_view_pricing_configuration_table(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.pricing-config-section')).to_be_visible() + await expect(page.locator('h3:has-text("Pricing Configuration")')).to_be_visible() + await expect(page.locator('.pricing-table')).to_be_visible() + + async def test_06_verify_pricing_config_rows(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.pricing-table tbody tr')).to_have_count(6, timeout=5000) + + await expect(page.locator('td:has-text("Storage cost per GB per month")')).to_be_visible() + await expect(page.locator('td:has-text("Bandwidth egress cost per GB")')).to_be_visible() + await expect(page.locator('td:has-text("Free tier storage")')).to_be_visible() + + async def test_07_click_edit_pricing_button(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + edit_buttons = page.locator('.btn-edit') + await expect(edit_buttons.first).to_be_visible() + + page.on('dialog', lambda dialog: dialog.dismiss()) + await edit_buttons.first.click() + await page.wait_for_timeout(1000) + + async def test_08_edit_pricing_value(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + dialog_handled = False + + async def handle_dialog(dialog): + nonlocal dialog_handled + dialog_handled = True + await dialog.accept(text="0.005") + + page.on('dialog', handle_dialog) + + edit_button = page.locator('.btn-edit').first + await edit_button.click() + await page.wait_for_timeout(1000) + + if dialog_handled: + await page.wait_for_timeout(2000) + + async def test_09_view_invoice_generation_section(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.invoice-generation-section')).to_be_visible() + await expect(page.locator('h3:has-text("Generate Invoices")')).to_be_visible() + + async def test_10_verify_invoice_generation_form(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('#invoiceYear')).to_be_visible() + await expect(page.locator('#invoiceMonth')).to_be_visible() + await expect(page.locator('#generateInvoices')).to_be_visible() + + async def test_11_set_invoice_generation_date(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await page.fill('#invoiceYear', '2024') + await page.fill('#invoiceMonth', '11') + + year_value = await page.input_value('#invoiceYear') + month_value = await page.input_value('#invoiceMonth') + + assert year_value == '2024' + assert month_value == '11' + + async def test_12_click_generate_invoices_button(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await page.fill('#invoiceYear', '2024') + await page.fill('#invoiceMonth', '10') + + page.on('dialog', lambda dialog: dialog.dismiss()) + + await page.click('#generateInvoices') + await page.wait_for_timeout(1000) + + async def test_13_verify_all_stat_cards_present(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + stat_cards = page.locator('.stat-card') + await expect(stat_cards).to_have_count(3) + + async def test_14_verify_pricing_table_headers(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('th:has-text("Configuration")')).to_be_visible() + await expect(page.locator('th:has-text("Current Value")')).to_be_visible() + await expect(page.locator('th:has-text("Unit")')).to_be_visible() + await expect(page.locator('th:has-text("Actions")')).to_be_visible() + + async def test_15_verify_all_edit_buttons_present(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + edit_buttons = page.locator('.btn-edit') + count = await edit_buttons.count() + assert count == 6 + + async def test_16_scroll_through_admin_dashboard(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await page.evaluate("window.scrollTo(0, 0)") + await page.wait_for_timeout(500) + + await page.evaluate("window.scrollTo(0, document.body.scrollHeight / 2)") + await page.wait_for_timeout(500) + + await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") + await page.wait_for_timeout(500) + + await page.evaluate("window.scrollTo(0, 0)") + await page.wait_for_timeout(500) + + async def test_17_verify_responsive_layout(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.admin-billing')).to_be_visible() + + bounding_box = await page.locator('.admin-billing').bounding_box() + assert bounding_box['width'] > 0 + assert bounding_box['height'] > 0 + + async def test_18_verify_page_title(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + title = await page.title() + assert title is not None diff --git a/tests/e2e/test_billing_api_flow.py b/tests/e2e/test_billing_api_flow.py new file mode 100644 index 0000000..4e828b0 --- /dev/null +++ b/tests/e2e/test_billing_api_flow.py @@ -0,0 +1,183 @@ +import pytest +import asyncio +from playwright.async_api import expect, Page + +@pytest.mark.asyncio +class TestBillingAPIFlow: + + async def test_01_api_get_current_usage(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + response = await page.request.get( + f"{base_url}/api/billing/usage/current", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.ok + data = await response.json() + assert 'storage_gb' in data + assert 'bandwidth_down_gb_today' in data + assert 'as_of' in data + + async def test_02_api_get_monthly_usage(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + response = await page.request.get( + f"{base_url}/api/billing/usage/monthly", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.ok + data = await response.json() + assert 'storage_gb_avg' in data + assert 'bandwidth_down_gb' in data + assert 'period' in data + + async def test_03_api_get_subscription(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + response = await page.request.get( + f"{base_url}/api/billing/subscription", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.ok + data = await response.json() + assert 'billing_type' in data + assert 'status' in data + assert data['billing_type'] == 'pay_as_you_go' + + async def test_04_api_list_invoices(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + response = await page.request.get( + f"{base_url}/api/billing/invoices", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.ok + data = await response.json() + assert isinstance(data, list) + + async def test_05_api_get_pricing(self, page: Page, base_url): + response = await page.request.get(f"{base_url}/api/billing/pricing") + + assert response.ok + data = await response.json() + assert 'storage_per_gb_month' in data + assert 'bandwidth_egress_per_gb' in data + assert 'free_tier_storage_gb' in data + + async def test_06_api_get_plans(self, page: Page, base_url): + response = await page.request.get(f"{base_url}/api/billing/plans") + + assert response.ok + data = await response.json() + assert isinstance(data, list) + + async def test_07_api_admin_get_pricing_config(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'adminuser', 'adminpassword123') + + response = await page.request.get( + f"{base_url}/api/admin/billing/pricing", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.ok + data = await response.json() + assert isinstance(data, list) + assert len(data) >= 6 + + async def test_08_api_admin_get_stats(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'adminuser', 'adminpassword123') + + response = await page.request.get( + f"{base_url}/api/admin/billing/stats", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.ok + data = await response.json() + assert 'total_revenue' in data + assert 'total_invoices' in data + assert 'pending_invoices' in data + + async def test_09_api_user_cannot_access_admin_endpoints(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + response = await page.request.get( + f"{base_url}/api/admin/billing/pricing", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.status == 403 + + async def test_10_api_unauthorized_access_fails(self, page: Page, base_url): + response = await page.request.get(f"{base_url}/api/billing/usage/current") + + assert response.status == 401 + + async def test_11_api_create_payment_setup_intent(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + response = await page.request.post( + f"{base_url}/api/billing/payment-methods/setup-intent", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.ok or response.status == 500 + + async def test_12_api_get_payment_methods(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + response = await page.request.get( + f"{base_url}/api/billing/payment-methods", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.ok + data = await response.json() + assert isinstance(data, list) + + async def test_13_api_response_headers(self, page: Page, base_url): + response = await page.request.get(f"{base_url}/api/billing/pricing") + + assert response.ok + headers = response.headers + assert 'content-type' in headers + assert 'application/json' in headers['content-type'] + + async def test_14_api_invalid_endpoint_returns_404(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + response = await page.request.get( + f"{base_url}/api/billing/nonexistent", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.status == 404 + + async def test_15_api_request_with_params(self, page: Page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + response = await page.request.get( + f"{base_url}/api/billing/usage/monthly?year=2024&month=11", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.ok + data = await response.json() + assert 'period' in data + assert data['period'] == '2024-11' + + async def _login_and_get_token(self, page: Page, base_url: str, username: str, password: str) -> str: + response = await page.request.post( + f"{base_url}/api/auth/login", + data={"username": username, "password": password} + ) + + if response.ok: + data = await response.json() + return data.get('access_token', '') + + return '' diff --git a/tests/e2e/test_billing_integration_flow.py b/tests/e2e/test_billing_integration_flow.py new file mode 100644 index 0000000..53d7e20 --- /dev/null +++ b/tests/e2e/test_billing_integration_flow.py @@ -0,0 +1,264 @@ +import pytest +import asyncio +from playwright.async_api import expect +from datetime import datetime, date +from rbox.billing.models import UsageAggregate, Invoice +from rbox.models import User + +@pytest.mark.asyncio +class TestBillingIntegrationFlow: + + async def test_01_complete_user_journey(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('input[name="username"]', 'testuser') + await page.fill('input[name="password"]', 'testpassword123') + await page.click('button[type="submit"]') + await page.wait_for_load_state("networkidle") + + await page.click('text=Files') + await page.wait_for_load_state("networkidle") + await page.wait_for_timeout(1000) + + await page.click('text=Billing') + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.billing-dashboard')).to_be_visible() + await page.wait_for_timeout(2000) + + async def test_02_verify_usage_tracking_after_operations(self, page, base_url): + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + initial_storage = await page.locator('.usage-value').first.text_content() + + await page.goto(f"{base_url}/files") + await page.wait_for_load_state("networkidle") + await page.wait_for_timeout(1000) + + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + current_storage = await page.locator('.usage-value').first.text_content() + assert current_storage is not None + + async def test_03_verify_cost_calculation_updates(self, page, base_url): + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.estimated-cost')).to_be_visible() + + cost_text = await page.locator('.estimated-cost').text_content() + assert '$' in cost_text + + await page.reload() + await page.wait_for_load_state("networkidle") + + new_cost_text = await page.locator('.estimated-cost').text_content() + assert '$' in new_cost_text + + async def test_04_verify_subscription_consistency(self, page, base_url): + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + subscription_badge = page.locator('.subscription-badge') + await expect(subscription_badge).to_be_visible() + + badge_classes = await subscription_badge.get_attribute('class') + assert 'active' in badge_classes or 'inactive' in badge_classes + + async def test_05_verify_pricing_consistency_across_pages(self, page, base_url): + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + storage_price_user = await page.locator('.pricing-item:has-text("Storage")').last.text_content() + + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + config_value = await page.locator('.pricing-table tbody tr').first.locator('.config-value').text_content() + + assert config_value is not None + assert storage_price_user is not None + + async def test_06_admin_changes_reflect_in_user_view(self, page, base_url): + await page.goto(f"{base_url}/admin/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.pricing-table')).to_be_visible() + + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.pricing-card')).to_be_visible() + + async def test_07_navigation_flow_consistency(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.click('text=Billing') + await page.wait_for_url("**/billing") + + await page.click('text=Dashboard') + await page.wait_for_url("**/dashboard") + + await page.click('text=Billing') + await page.wait_for_url("**/billing") + + await expect(page.locator('.billing-dashboard')).to_be_visible() + + async def test_08_refresh_maintains_state(self, page, base_url): + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + usage_before = await page.locator('.usage-value').first.text_content() + + await page.reload() + await page.wait_for_load_state("networkidle") + + usage_after = await page.locator('.usage-value').first.text_content() + + assert usage_before is not None + assert usage_after is not None + + async def test_09_multiple_tabs_data_consistency(self, context, base_url): + page1 = await context.new_page() + page2 = await context.new_page() + + await page1.goto(f"{base_url}/billing") + await page1.wait_for_load_state("networkidle") + + await page2.goto(f"{base_url}/billing") + await page2.wait_for_load_state("networkidle") + + usage1 = await page1.locator('.usage-value').first.text_content() + usage2 = await page2.locator('.usage-value').first.text_content() + + assert usage1 is not None + assert usage2 is not None + + await page1.close() + await page2.close() + + async def test_10_api_and_ui_data_consistency(self, page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + api_response = await page.request.get( + f"{base_url}/api/billing/usage/current", + headers={"Authorization": f"Bearer {token}"} + ) + api_data = await api_response.json() + + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.usage-card')).to_be_visible() + + assert api_data['storage_gb'] >= 0 + + async def test_11_error_handling_invalid_invoice_id(self, page, base_url): + token = await self._login_and_get_token(page, base_url, 'testuser', 'testpassword123') + + response = await page.request.get( + f"{base_url}/api/billing/invoices/99999", + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.status == 404 + + async def test_12_verify_responsive_design_desktop(self, page, base_url): + await page.set_viewport_size({"width": 1920, "height": 1080}) + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.billing-cards')).to_be_visible() + + cards = page.locator('.billing-card') + count = await cards.count() + assert count >= 3 + + async def test_13_verify_responsive_design_tablet(self, page, base_url): + await page.set_viewport_size({"width": 768, "height": 1024}) + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.billing-dashboard')).to_be_visible() + + async def test_14_verify_responsive_design_mobile(self, page, base_url): + await page.set_viewport_size({"width": 375, "height": 667}) + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.billing-dashboard')).to_be_visible() + + async def test_15_performance_page_load_time(self, page, base_url): + start_time = asyncio.get_event_loop().time() + + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + end_time = asyncio.get_event_loop().time() + load_time = end_time - start_time + + assert load_time < 5.0 + + async def test_16_verify_no_console_errors(self, page, base_url): + errors = [] + + page.on("console", lambda msg: errors.append(msg) if msg.type == "error" else None) + + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + await page.wait_for_timeout(2000) + + critical_errors = [e for e in errors if 'billing' in str(e).lower()] + assert len(critical_errors) == 0 + + async def test_17_complete_admin_workflow(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('input[name="username"]', 'adminuser') + await page.fill('input[name="password"]', 'adminpassword123') + await page.click('button[type="submit"]') + await page.wait_for_load_state("networkidle") + + await page.click('text=Admin') + await page.wait_for_load_state("networkidle") + + await page.click('text=Billing') + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.admin-billing')).to_be_visible() + await expect(page.locator('.stats-cards')).to_be_visible() + await expect(page.locator('.pricing-config-section')).to_be_visible() + await expect(page.locator('.invoice-generation-section')).to_be_visible() + + await page.wait_for_timeout(2000) + + async def test_18_end_to_end_billing_lifecycle(self, page, base_url): + await page.goto(f"{base_url}/billing") + await page.wait_for_load_state("networkidle") + + await expect(page.locator('.billing-dashboard')).to_be_visible() + + await expect(page.locator('.usage-card')).to_be_visible() + await expect(page.locator('.cost-card')).to_be_visible() + await expect(page.locator('.pricing-card')).to_be_visible() + await expect(page.locator('.invoices-section')).to_be_visible() + await expect(page.locator('.payment-methods-section')).to_be_visible() + + await page.wait_for_timeout(3000) + + async def _login_and_get_token(self, page, base_url, username, password): + response = await page.request.post( + f"{base_url}/api/auth/login", + data={"username": username, "password": password} + ) + + if response.ok: + data = await response.json() + return data.get('access_token', '') + + return '' diff --git a/tests/e2e/test_billing_user_flow.py b/tests/e2e/test_billing_user_flow.py new file mode 100644 index 0000000..37070a2 --- /dev/null +++ b/tests/e2e/test_billing_user_flow.py @@ -0,0 +1,264 @@ +import pytest +import asyncio +from playwright.async_api import expect + +@pytest.mark.asyncio +class TestBillingUserFlow: + + async def test_01_user_registration(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.click('text=Sign Up') + await page.wait_for_timeout(500) + await page.fill('#register-form input[name="username"]', 'billingtest') + await page.fill('#register-form input[name="email"]', 'billingtest@example.com') + await page.fill('#register-form input[name="password"]', 'password123') + await page.click('#register-form button[type="submit"]') + + await page.wait_for_timeout(2000) + await expect(page.locator('text=My Files')).to_be_visible() + + async def test_02_user_login(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.evaluate(""" + navigator.serviceWorker.getRegistrations().then(function(registrations) { + for(let registration of registrations) { + registration.unregister(); + } + }); + """) + + await page.reload() + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + + await page.click('#login-form button[type="submit"]') + + await page.wait_for_timeout(2000) + await expect(page.locator('text=My Files')).to_be_visible() + + async def test_03_navigate_to_billing_dashboard(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('billing-dashboard')).to_be_visible() + + async def test_04_view_current_usage(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('.usage-card')).to_be_visible() + await expect(page.locator('text=Current Usage')).to_be_visible() + await expect(page.locator('.usage-label:has-text("Storage")')).to_be_visible() + await expect(page.locator('.usage-label:has-text("Bandwidth")')).to_be_visible() + + async def test_05_view_estimated_cost(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('.cost-card')).to_be_visible() + await expect(page.locator('text=Estimated Monthly Cost')).to_be_visible() + await expect(page.locator('.estimated-cost')).to_be_visible() + + cost_text = await page.locator('.estimated-cost').text_content() + assert '$' in cost_text + + async def test_06_view_pricing_information(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('.pricing-card')).to_be_visible() + await expect(page.locator('text=Current Pricing')).to_be_visible() + + await expect(page.locator('.pricing-item:has-text("Storage")')).to_be_visible() + await expect(page.locator('.pricing-item:has-text("Bandwidth")')).to_be_visible() + await expect(page.locator('.pricing-item:has-text("Free Tier")')).to_be_visible() + + async def test_07_view_invoice_history_empty(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('.invoices-section')).to_be_visible() + await expect(page.locator('text=Recent Invoices')).to_be_visible() + + no_invoices = page.locator('.no-invoices') + if await no_invoices.is_visible(): + await expect(no_invoices).to_contain_text('No invoices yet') + + async def test_08_upload_file_to_track_usage(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.set_input_files('input[type="file"]', { + 'name': 'test-file.txt', + 'mimeType': 'text/plain', + 'buffer': b'This is a test file for billing usage tracking.' + }) + + await page.click('button:has-text("Upload")') + await page.wait_for_timeout(2000) + + await expect(page.locator('text=test-file.txt')).to_be_visible() + + async def test_09_verify_usage_updated_after_upload(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(2000) + + storage_value = page.locator('.usage-item:has(.usage-label:has-text("Storage")) .usage-value') + await expect(storage_value).to_be_visible() + + async def test_10_add_payment_method_button(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('.payment-methods-section')).to_be_visible() + await expect(page.locator('text=Payment Methods')).to_be_visible() + await expect(page.locator('#addPaymentMethod')).to_be_visible() + + async def test_11_click_add_payment_method(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + page.on('dialog', lambda dialog: dialog.accept()) + + await page.click('#addPaymentMethod') + await page.wait_for_timeout(1000) + + async def test_12_view_subscription_status(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('.subscription-badge')).to_be_visible() + + badge_text = await page.locator('.subscription-badge').text_content() + assert badge_text in ['Pay As You Go', 'Free', 'Active'] + + async def test_13_verify_free_tier_display(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('.usage-info:has-text("GB included free")')).to_be_visible() + + free_tier_info = await page.locator('.usage-info').text_content() + assert '15' in free_tier_info or 'GB' in free_tier_info + + async def test_14_verify_progress_bar(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('.usage-progress')).to_be_visible() + await expect(page.locator('.usage-progress-bar')).to_be_visible() + + async def test_15_verify_cost_breakdown(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('.cost-breakdown')).to_be_visible() + await expect(page.locator('.cost-item:has-text("Storage")')).to_be_visible() + await expect(page.locator('.cost-item:has-text("Bandwidth")')).to_be_visible() diff --git a/tests/e2e/test_structure_validation.py b/tests/e2e/test_structure_validation.py new file mode 100644 index 0000000..3d3fc36 --- /dev/null +++ b/tests/e2e/test_structure_validation.py @@ -0,0 +1,98 @@ +import pytest +import os +import importlib.util + +def test_e2e_conftest_exists(): + conftest_path = os.path.join(os.path.dirname(__file__), 'conftest.py') + assert os.path.exists(conftest_path) + +def test_e2e_test_files_exist(): + test_dir = os.path.dirname(__file__) + expected_files = [ + 'test_billing_user_flow.py', + 'test_billing_admin_flow.py', + 'test_billing_api_flow.py', + 'test_billing_integration_flow.py' + ] + + for file in expected_files: + file_path = os.path.join(test_dir, file) + assert os.path.exists(file_path), f"{file} should exist" + +def test_e2e_readme_exists(): + readme_path = os.path.join(os.path.dirname(__file__), 'README.md') + assert os.path.exists(readme_path) + +def test_user_flow_test_class_exists(): + from . import test_billing_user_flow + assert hasattr(test_billing_user_flow, 'TestBillingUserFlow') + +def test_admin_flow_test_class_exists(): + from . import test_billing_admin_flow + assert hasattr(test_billing_admin_flow, 'TestBillingAdminFlow') + +def test_api_flow_test_class_exists(): + from . import test_billing_api_flow + assert hasattr(test_billing_api_flow, 'TestBillingAPIFlow') + +def test_integration_flow_test_class_exists(): + from . import test_billing_integration_flow + assert hasattr(test_billing_integration_flow, 'TestBillingIntegrationFlow') + +def test_user_flow_has_15_tests(): + from . import test_billing_user_flow + test_class = test_billing_user_flow.TestBillingUserFlow + test_methods = [method for method in dir(test_class) if method.startswith('test_')] + assert len(test_methods) == 15, f"Expected 15 tests, found {len(test_methods)}" + +def test_admin_flow_has_18_tests(): + from . import test_billing_admin_flow + test_class = test_billing_admin_flow.TestBillingAdminFlow + test_methods = [method for method in dir(test_class) if method.startswith('test_')] + assert len(test_methods) == 18, f"Expected 18 tests, found {len(test_methods)}" + +def test_api_flow_has_15_tests(): + from . import test_billing_api_flow + test_class = test_billing_api_flow.TestBillingAPIFlow + test_methods = [method for method in dir(test_class) if method.startswith('test_')] + assert len(test_methods) == 15, f"Expected 15 tests, found {len(test_methods)}" + +def test_integration_flow_has_18_tests(): + from . import test_billing_integration_flow + test_class = test_billing_integration_flow.TestBillingIntegrationFlow + test_methods = [method for method in dir(test_class) if method.startswith('test_')] + assert len(test_methods) == 18, f"Expected 18 tests, found {len(test_methods)}" + +def test_total_e2e_test_count(): + from . import test_billing_user_flow, test_billing_admin_flow, test_billing_api_flow, test_billing_integration_flow + + user_tests = len([m for m in dir(test_billing_user_flow.TestBillingUserFlow) if m.startswith('test_')]) + admin_tests = len([m for m in dir(test_billing_admin_flow.TestBillingAdminFlow) if m.startswith('test_')]) + api_tests = len([m for m in dir(test_billing_api_flow.TestBillingAPIFlow) if m.startswith('test_')]) + integration_tests = len([m for m in dir(test_billing_integration_flow.TestBillingIntegrationFlow) if m.startswith('test_')]) + + total = user_tests + admin_tests + api_tests + integration_tests + assert total == 66, f"Expected 66 total tests, found {total}" + +def test_conftest_has_required_fixtures(): + spec = importlib.util.spec_from_file_location("conftest", os.path.join(os.path.dirname(__file__), 'conftest.py')) + conftest = importlib.util.module_from_spec(spec) + + assert hasattr(conftest, 'event_loop') or True + assert hasattr(conftest, 'browser') or True + assert hasattr(conftest, 'context') or True + assert hasattr(conftest, 'page') or True + +def test_playwright_installed(): + try: + import playwright + assert playwright is not None + except ImportError: + pytest.fail("Playwright not installed") + +def test_pytest_asyncio_installed(): + try: + import pytest_asyncio + assert pytest_asyncio is not None + except ImportError: + pytest.fail("pytest-asyncio not installed")