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
+
+
+
+ | Configuration |
+ Current Value |
+ Unit |
+ Actions |
+
+
+
+ ${this.pricingConfig.map(config => `
+
+ | ${config.description || config.config_key} |
+ ${config.config_value} |
+ ${config.unit || '-'} |
+
+
+ |
+
+ `).join('')}
+
+
+
+
+
+
+ `;
+ }
+
+ 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}
+
+
+ `;
+ }
+
+ 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}
+
+
+ `;
+ }
+
+ 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 = `
+
+
+
+
+
+
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 `
+
+
+
+ | Invoice # |
+ Period |
+ Amount |
+ Status |
+ Due Date |
+ Actions |
+
+
+
+ ${this.invoices.map(invoice => `
+
+ | ${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) : '-'} |
+
+
+ |
+
+ `).join('')}
+
+
+ `;
+ }
+
+ 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 = `
+
+
Invoice ${invoice.invoice_number}
+
+
Period: ${this.formatDate(invoice.period_start)} - ${this.formatDate(invoice.period_end)}
+
Status: ${invoice.status}
+
Line Items
+
+
+
+ | Description |
+ Quantity |
+ Unit Price |
+ Amount |
+
+
+
+ ${invoice.line_items.map(item => `
+
+ | ${item.description} |
+ ${item.quantity.toFixed(2)} |
+ ${this.formatCurrency(item.unit_price)} |
+ ${this.formatCurrency(item.amount)} |
+
+ `).join('')}
+
+
+
+
Subtotal: ${this.formatCurrency(invoice.subtotal)}
+
Tax: ${this.formatCurrency(invoice.tax)}
+
Total: ${this.formatCurrency(invoice.total)}
+
+
+
+
+ `;
+ 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 = `
+
+ `;
+ }
+
+ 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
+
+
No deleted files found.
`;
@@ -32,76 +43,110 @@ export class DeletedFiles extends HTMLElement {
}
this.innerHTML = `
-
-
Deleted Files
+
+
+
+ ${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 ? `
+
+ Home
+ ${this.folderPath.map((folder, index) => `
+ /
+ ${folder.name}
+ `).join('')}
+
+ ` : ''}
-
-
-
-
-
-
-
-
-
+ ${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.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
+
`;
@@ -34,23 +45,45 @@ export class RecentFiles extends HTMLElement {
}
this.innerHTML = `
-
-
Recent Files
+
+
+
+ ${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
+
+
No starred items found.
`;
@@ -38,71 +49,90 @@ export class StarredItems extends HTMLElement {
}
this.innerHTML = `
-
-
Starred Items
+
+
+
+ ${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 `
-
+
+
`;
}
- 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")