This commit is contained in:
retoor 2025-11-10 15:46:40 +01:00
parent 1ddb2c609d
commit 1e5a6dbd5f
63 changed files with 6107 additions and 491 deletions

152
Makefile
View File

@ -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"

View File

@ -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):

0
rbox/billing/__init__.py Normal file
View File

View File

@ -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

139
rbox/billing/models.py Normal file
View File

@ -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"

57
rbox/billing/scheduler.py Normal file
View File

@ -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()

View File

@ -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)

View File

@ -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)
}

View File

@ -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():

View File

View File

@ -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

View File

@ -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
}

View File

@ -65,13 +65,13 @@ 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"},
)

312
rbox/routers/billing.py Normal file
View File

@ -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
]

View File

@ -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
@ -138,6 +137,8 @@ async def delete_file(file_id: int, current_user: User = Depends(get_current_use
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)
await storage_manager.save_file(current_user.id, new_storage_path, content_bytes)
return [await FileOut.from_tortoise_orm(f) for f in updated_files]
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)

View File

@ -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]

View File

@ -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",
@ -107,9 +109,64 @@ async def access_shared_content(share_token: str, password: Optional[str] = None
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)):

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

371
static/css/billing.css Normal file
View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 157 KiB

View File

@ -5,7 +5,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RBox Cloud Storage</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/billing.css">
<link rel="stylesheet" href="/static/lib/codemirror/codemirror.min.css">
<link rel="stylesheet" href="/static/css/code-editor-view.css">
<link rel="stylesheet" href="/static/css/file-upload-view.css">
<link rel="manifest" href="/static/manifest.json">
<script src="/static/lib/codemirror/codemirror.min.js"></script>
<script src="/static/lib/codemirror/javascript.min.js"></script>
<script src="/static/lib/codemirror/python.min.js"></script>
<script src="/static/lib/codemirror/markdown.min.js"></script>
<script src="/static/lib/codemirror/xml.min.js"></script>
<script src="/static/lib/codemirror/css.min.js"></script>
<link rel="icon" type="image/png" href="/static/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
</head>
<body>
<rbox-app></rbox-app>

View File

@ -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();

View File

@ -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 = `
<div class="admin-billing">
<h2>Billing Administration</h2>
<div class="stats-cards">
<div class="stat-card">
<h3>Total Revenue</h3>
<div class="stat-value">${this.formatCurrency(this.stats?.total_revenue || 0)}</div>
</div>
<div class="stat-card">
<h3>Total Invoices</h3>
<div class="stat-value">${this.stats?.total_invoices || 0}</div>
</div>
<div class="stat-card">
<h3>Pending Invoices</h3>
<div class="stat-value">${this.stats?.pending_invoices || 0}</div>
</div>
</div>
<div class="pricing-config-section">
<h3>Pricing Configuration</h3>
<table class="pricing-table">
<thead>
<tr>
<th>Configuration</th>
<th>Current Value</th>
<th>Unit</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${this.pricingConfig.map(config => `
<tr data-config-id="${config.id}">
<td>${config.description || config.config_key}</td>
<td class="config-value">${config.config_value}</td>
<td>${config.unit || '-'}</td>
<td>
<button class="btn-edit" data-config-id="${config.id}">Edit</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div class="invoice-generation-section">
<h3>Generate Invoices</h3>
<div class="invoice-gen-form">
<label>
Year:
<input type="number" id="invoiceYear" value="${new Date().getFullYear()}" min="2020">
</label>
<label>
Month:
<input type="number" id="invoiceMonth" value="${new Date().getMonth() + 1}" min="1" max="12">
</label>
<button class="btn-primary" id="generateInvoices">Generate All Invoices</button>
</div>
</div>
</div>
`;
}
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;

View File

@ -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 {
</div>
</div>
`;
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);
}
}

View File

@ -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 '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
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 ? '&#9733;' : '&#9734;';
const starAction = folder.is_starred ? 'unstar-folder' : 'star-folder';
const actions = this.getFolderActions(folder);
return `
<div class="file-item folder-item ${isSelected ? 'selected' : ''}" data-folder-id="${folder.id}">
<input type="checkbox" class="select-item" data-type="folder" data-id="${folder.id}" ${isSelected ? 'checked' : ''}>
<div class="file-icon">&#128193;</div>
<div class="file-name">${folder.name}</div>
<div class="file-actions-menu">
${actions}
</div>
</div>
`;
}
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 ? '&#9733;' : '&#9734;';
const starAction = file.is_starred ? 'unstar-file' : 'star-file';
const actions = this.getFileActions(file);
return `
<div class="file-item ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
<input type="checkbox" class="select-item" data-type="file" data-id="${file.id}" ${isSelected ? 'checked' : ''}>
<div class="file-icon">${icon}</div>
<div class="file-name">${file.name}</div>
<div class="file-size">${size}</div>
<div class="file-actions-menu">
${actions}
</div>
</div>
`;
}
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) {
}
}

View File

@ -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 = `
<div class="billing-dashboard">
<div class="billing-header">
<h2>Billing & Usage</h2>
<div class="subscription-badge ${this.subscription?.status === 'active' ? 'active' : 'inactive'}">
${this.subscription?.billing_type === 'pay_as_you_go' ? 'Pay As You Go' : this.subscription?.plan_name || 'Free'}
</div>
</div>
<div class="billing-cards">
<div class="billing-card usage-card">
<h3>Current Usage</h3>
<div class="usage-details">
<div class="usage-item">
<span class="usage-label">Storage</span>
<span class="usage-value">${this.formatGB(storageUsed)}</span>
</div>
<div class="usage-progress">
<div class="usage-progress-bar" style="width: ${storagePercentage}%"></div>
</div>
<div class="usage-info">${this.formatGB(freeStorage)} included free</div>
<div class="usage-item">
<span class="usage-label">Bandwidth (Today)</span>
<span class="usage-value">${this.formatGB(this.currentUsage?.bandwidth_down_gb_today || 0)}</span>
</div>
</div>
</div>
<div class="billing-card cost-card">
<h3>Estimated Monthly Cost</h3>
<div class="estimated-cost">${this.formatCurrency(estimatedCost)}</div>
<div class="cost-breakdown">
<div class="cost-item">
<span>Storage</span>
<span>${this.formatCurrency(Math.max(0, Math.ceil(storageUsed - freeStorage)) * parseFloat(this.pricing?.storage_per_gb_month?.value || 0))}</span>
</div>
<div class="cost-item">
<span>Bandwidth</span>
<span>${this.formatCurrency(0)}</span>
</div>
</div>
</div>
<div class="billing-card pricing-card">
<h3>Current Pricing</h3>
<div class="pricing-details">
<div class="pricing-item">
<span>Storage</span>
<span>${this.formatCurrency(parseFloat(this.pricing?.storage_per_gb_month?.value || 0))}/GB/month</span>
</div>
<div class="pricing-item">
<span>Bandwidth</span>
<span>${this.formatCurrency(parseFloat(this.pricing?.bandwidth_egress_per_gb?.value || 0))}/GB</span>
</div>
<div class="pricing-item">
<span>Free Tier</span>
<span>${this.formatGB(freeStorage)} storage, ${this.formatGB(parseFloat(this.pricing?.free_tier_bandwidth_gb?.value || 15))} bandwidth/month</span>
</div>
</div>
</div>
</div>
<div class="invoices-section">
<h3>Recent Invoices</h3>
<div class="invoices-table">
${this.renderInvoicesTable()}
</div>
</div>
<div class="payment-methods-section">
<h3>Payment Methods</h3>
<button class="btn-primary" id="addPaymentMethod">Add Payment Method</button>
</div>
</div>
`;
}
renderInvoicesTable() {
if (!this.invoices || this.invoices.length === 0) {
return '<p class="no-invoices">No invoices yet</p>';
}
return `
<table>
<thead>
<tr>
<th>Invoice #</th>
<th>Period</th>
<th>Amount</th>
<th>Status</th>
<th>Due Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${this.invoices.map(invoice => `
<tr>
<td>${invoice.invoice_number}</td>
<td>${this.formatDate(invoice.period_start)} - ${this.formatDate(invoice.period_end)}</td>
<td>${this.formatCurrency(invoice.total)}</td>
<td><span class="invoice-status ${invoice.status}">${invoice.status}</span></td>
<td>${invoice.due_date ? this.formatDate(invoice.due_date) : '-'}</td>
<td>
<button class="btn-link" data-invoice-id="${invoice.id}">View</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
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 = `
<div class="modal-content">
<h2>Invoice ${invoice.invoice_number}</h2>
<div class="invoice-details">
<p><strong>Period:</strong> ${this.formatDate(invoice.period_start)} - ${this.formatDate(invoice.period_end)}</p>
<p><strong>Status:</strong> ${invoice.status}</p>
<h3>Line Items</h3>
<table>
<thead>
<tr>
<th>Description</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
${invoice.line_items.map(item => `
<tr>
<td>${item.description}</td>
<td>${item.quantity.toFixed(2)}</td>
<td>${this.formatCurrency(item.unit_price)}</td>
<td>${this.formatCurrency(item.amount)}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="invoice-total">
<div><strong>Subtotal:</strong> ${this.formatCurrency(invoice.subtotal)}</div>
<div><strong>Tax:</strong> ${this.formatCurrency(invoice.tax)}</div>
<div><strong>Total:</strong> ${this.formatCurrency(invoice.total)}</div>
</div>
</div>
<button class="btn-secondary" onclick="this.closest('.modal').remove()">Close</button>
</div>
`;
document.body.appendChild(modal);
}
}
customElements.define('billing-dashboard', BillingDashboard);
export default BillingDashboard;

View File

@ -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 = `
<div class="code-editor-view">
<div class="code-editor-header">
<div class="header-left">
<button class="button" id="back-btn">Back</button>
<h2 class="editor-filename">${this.file.name}</h2>
</div>
<div class="header-right">
<button class="button button-primary" id="save-btn">Save</button>
</div>
</div>
<div class="code-editor-body">
<textarea id="code-editor-textarea">${content}</textarea>
</div>
</div>
`;
}
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 };

View File

@ -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 = `
<div class="deleted-files-container">
<h2>Deleted Files</h2>
<div class="file-list-container">
<div class="file-list-header">
<h2>Deleted Files</h2>
</div>
<p class="empty-state">No deleted files found.</p>
</div>
`;
@ -32,76 +43,110 @@ export class DeletedFiles extends HTMLElement {
}
this.innerHTML = `
<div class="deleted-files-container">
<h2>Deleted Files</h2>
<div class="file-list-container">
<div class="file-list-header">
<div class="header-left">
<h2>Deleted Files</h2>
${this.files.length > 0 ? `
<div class="selection-controls">
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''} ${someSelected ? 'data-indeterminate="true"' : ''}>
<label for="select-all">
${hasSelected ? `${totalSelected} selected` : 'Select all'}
</label>
</div>
` : ''}
</div>
</div>
${hasSelected ? `
<div class="batch-actions">
<button class="button button-small button-primary" id="batch-restore-btn">Restore Selected</button>
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
</div>
` : ''}
<div class="file-grid">
${this.deletedFiles.map(file => this.renderDeletedFile(file)).join('')}
${this.files.map(file => this.renderDeletedFile(file)).join('')}
</div>
</div>
`;
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 `
<div class="file-item deleted-item" data-file-id="${file.id}">
<div class="file-item ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
<input type="checkbox" class="select-item" data-type="file" data-id="${file.id}" ${isSelected ? 'checked' : ''}>
<div class="file-icon">${icon}</div>
<div class="file-name">${file.name}</div>
<div class="file-size">${size}</div>
<div class="file-deleted-date">Deleted: ${deletedDate}</div>
<div class="file-deleted-at">Deleted: ${deletedAt}</div>
<div class="file-actions-menu">
<button class="button button-danger action-btn" data-action="restore" data-id="${file.id}">Restore</button>
<button class="action-btn" data-action="restore" data-id="${file.id}">Restore</button>
</div>
</div>
`;
}
getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
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 = `
<button class="button button-small button-primary" id="batch-restore-btn">Restore Selected</button>
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
`;
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' }
}));
}
}
}

View File

@ -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 = `
<div class="file-list-container">
${this.folderPath.length > 0 ? `
<div class="breadcrumb-nav">
<span class="breadcrumb-item" data-folder-id="null">Home</span>
${this.folderPath.map((folder, index) => `
<span class="breadcrumb-separator">/</span>
<span class="breadcrumb-item ${index === this.folderPath.length - 1 ? 'breadcrumb-current' : ''}" data-folder-id="${folder.id}">${folder.name}</span>
`).join('')}
</div>
` : ''}
<div class="file-list-header">
<h2>Files</h2>
<div class="header-left">
<h2>Files</h2>
${totalItems > 0 ? `
<div class="selection-controls">
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''} ${someSelected ? 'data-indeterminate="true"' : ''}>
<label for="select-all">
${hasSelected ? `${totalSelected} selected` : 'Select all'}
</label>
</div>
` : ''}
</div>
<div class="file-actions">
<button class="button" id="create-folder-btn">New Folder</button>
<button class="button button-primary" id="upload-btn">Upload</button>
</div>
</div>
<div class="batch-actions" style="display: ${hasSelected ? 'flex' : 'none'};">
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''}>
<label for="select-all">Select All</label>
<button class="button button-small button-danger" id="batch-delete-btn">Delete Selected</button>
<button class="button button-small" id="batch-move-btn">Move Selected</button>
<button class="button button-small" id="batch-copy-btn">Copy Selected</button>
<button class="button button-small" id="batch-star-btn">Star Selected</button>
<button class="button button-small" id="batch-unstar-btn">Unstar Selected</button>
</div>
${hasSelected ? `
<div class="batch-actions">
<button class="button button-small button-danger" id="batch-delete-btn">Delete</button>
<button class="button button-small" id="batch-move-btn">Move</button>
<button class="button button-small" id="batch-copy-btn">Copy</button>
<button class="button button-small" id="batch-star-btn">Star</button>
<button class="button button-small" id="batch-unstar-btn">Unstar</button>
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
</div>
` : ''}
<div class="file-grid">
${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 '&#128196;';
}
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 = `
<button class="button button-small button-danger" id="batch-delete-btn">Delete</button>
<button class="button button-small" id="batch-move-btn">Move</button>
<button class="button button-small" id="batch-copy-btn">Copy</button>
<button class="button button-small" id="batch-star-btn">Star</button>
<button class="button button-small" id="batch-unstar-btn">Unstar</button>
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
`;
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) {

View File

@ -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 = `
<div class="preview-modal">
<div class="preview-container">
<div class="preview-header">
<div class="file-preview-overlay">
<div class="file-preview-header">
<div class="header-left">
<button class="button close-preview">Back</button>
<div class="preview-info">
<h3 class="preview-file-name"></h3>
<h2 class="preview-file-name"></h2>
<p class="preview-file-info"></p>
</div>
<div class="preview-actions">
<button class="btn btn-icon download-btn" title="Download"></button>
<button class="btn btn-icon share-btn" title="Share">🔗</button>
<button class="btn btn-icon close-preview" title="Close"></button>
</div>
</div>
<div class="preview-content"></div>
<div class="preview-actions">
<button class="button download-btn">Download</button>
<button class="button share-btn">Share</button>
</div>
</div>
<div class="preview-content"></div>
</div>
`;
this.style.display = 'none';

View File

@ -0,0 +1,187 @@
import { api } from '../api.js';
export class FileUploadView extends HTMLElement {
constructor() {
super();
this.folderId = null;
this.handleEscape = this.handleEscape.bind(this);
}
connectedCallback() {
document.addEventListener('keydown', this.handleEscape);
}
disconnectedCallback() {
document.removeEventListener('keydown', this.handleEscape);
}
setFolder(folderId) {
this.folderId = folderId;
this.render();
this.attachListeners();
this.openFileSelector();
}
render() {
const folderInfo = this.folderId ? `(Folder ID: ${this.folderId})` : '(Root)';
this.innerHTML = `
<div class="file-upload-view" style="display: none;">
<div class="file-upload-header">
<div class="header-left">
<button class="button" id="upload-back-btn">Back</button>
<h2>Uploading Files ${folderInfo}</h2>
</div>
</div>
<div class="file-upload-body">
<div class="upload-list" id="upload-list"></div>
</div>
<input type="file" id="file-input" multiple style="display: none;">
</div>
`;
}
openFileSelector() {
const fileInput = this.querySelector('#file-input');
if (fileInput) {
fileInput.click();
}
}
attachListeners() {
const fileInput = this.querySelector('#file-input');
const backBtn = this.querySelector('#upload-back-btn');
if (backBtn) {
backBtn.addEventListener('click', () => this.close());
}
if (fileInput) {
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
const view = this.querySelector('.file-upload-view');
if (view) {
view.style.display = 'flex';
}
this.handleFiles(e.target.files);
} else {
this.close();
}
});
fileInput.addEventListener('cancel', () => {
this.close();
});
}
}
handleEscape(e) {
if (e.key === 'Escape') {
this.close();
}
}
close() {
window.history.back();
}
hide() {
document.removeEventListener('keydown', this.handleEscape);
this.remove();
}
async handleFiles(files) {
const uploadList = this.querySelector('#upload-list');
if (!uploadList) return;
for (const file of files) {
const itemId = `upload-${Date.now()}-${Math.random()}`;
const item = document.createElement('div');
item.className = 'upload-item';
item.id = itemId;
item.innerHTML = `
<div class="upload-item-info">
<div class="upload-item-name">${file.name}</div>
<div class="upload-item-size">${this.formatFileSize(file.size)}</div>
</div>
<div class="upload-item-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: 0%"></div>
</div>
<div class="upload-item-status">Uploading...</div>
</div>
`;
uploadList.appendChild(item);
try {
await this.uploadFile(file, itemId);
const statusEl = item.querySelector('.upload-item-status');
if (statusEl) {
statusEl.textContent = 'Complete';
statusEl.classList.add('success');
}
} catch (error) {
const statusEl = item.querySelector('.upload-item-status');
if (statusEl) {
statusEl.textContent = 'Failed: ' + error.message;
statusEl.classList.add('error');
}
}
}
this.dispatchEvent(new CustomEvent('upload-complete', { bubbles: true }));
setTimeout(() => {
this.close();
}, 1500);
}
async uploadFile(file, itemId) {
const formData = new FormData();
formData.append('file', file);
if (this.folderId !== null && this.folderId !== undefined) {
formData.append('folder_id', String(this.folderId));
}
const xhr = new XMLHttpRequest();
return new Promise((resolve, reject) => {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
const item = this.querySelector(`#${itemId}`);
if (item) {
const progressFill = item.querySelector('.progress-fill');
if (progressFill) {
progressFill.style.width = percentComplete + '%';
}
}
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(xhr.statusText));
}
});
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
xhr.open('POST', '/files/upload');
xhr.setRequestHeader('Authorization', `Bearer ${api.getToken()}`);
xhr.send(formData);
});
}
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
}
customElements.define('file-upload-view', FileUploadView);

View File

@ -16,7 +16,7 @@ export class LoginView extends HTMLElement {
<div class="auth-tabs">
<button class="auth-tab active" data-tab="login">Login</button>
<button class="auth-tab" data-tab="register">Register</button>
<button class="auth-tab" data-tab="register">Sign Up</button>
</div>
<form id="login-form" class="auth-form">

View File

@ -4,13 +4,19 @@ class PhotoGallery extends HTMLElement {
constructor() {
super();
this.photos = [];
this.boundHandleClick = this.handleClick.bind(this);
}
connectedCallback() {
this.addEventListener('click', this.boundHandleClick);
this.render();
this.loadPhotos();
}
disconnectedCallback() {
this.removeEventListener('click', this.boundHandleClick);
}
async loadPhotos() {
try {
this.photos = await api.getPhotos();
@ -62,16 +68,22 @@ class PhotoGallery extends HTMLElement {
}
});
grid.querySelectorAll('.photo-item').forEach(item => {
item.addEventListener('click', () => {
const fileId = item.dataset.fileId;
const photo = this.photos.find(p => p.id === parseInt(fileId));
this.dispatchEvent(new CustomEvent('photo-click', {
detail: { photo },
bubbles: true
}));
});
});
this.attachListeners();
}
handleClick(e) {
const photoItem = e.target.closest('.photo-item');
if (photoItem) {
const fileId = photoItem.dataset.fileId;
const photo = this.photos.find(p => p.id === parseInt(fileId));
this.dispatchEvent(new CustomEvent('photo-click', {
detail: { photo },
bubbles: true
}));
}
}
attachListeners() {
}
render() {

View File

@ -1,7 +1,7 @@
import { api } from '../api.js';
import './login-view.js';
import './file-list.js';
import './file-upload.js';
import './file-upload-view.js';
import './share-modal.js';
import './photo-gallery.js';
import './file-preview.js';
@ -10,20 +10,24 @@ import './admin-dashboard.js';
import './toast-notification.js';
import './starred-items.js';
import './recent-files.js';
import './shared-items.js'; // Import the new component
import './shared-items.js';
import './billing-dashboard.js';
import './admin-billing.js';
import './code-editor-view.js';
import { shortcuts } from '../shortcuts.js';
export class RBoxApp extends HTMLElement {
constructor() {
super();
this.currentView = 'files';
this.currentFolderId = null;
this.user = null;
this.navigationStack = [];
}
async connectedCallback() {
await this.init();
this.addEventListener('show-toast', this.handleShowToast);
window.addEventListener('popstate', this.handlePopState.bind(this));
}
disconnectedCallback() {
@ -85,7 +89,9 @@ export class RBoxApp extends HTMLElement {
<li><a href="#" class="nav-link" data-view="photos">Photo Gallery</a></li>
<li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li>
<li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li>
<li><a href="#" class="nav-link" data-view="billing">Billing</a></li>
${this.user && this.user.is_superuser ? `<li><a href="#" class="nav-link" data-view="admin">Admin Dashboard</a></li>` : ''}
${this.user && this.user.is_superuser ? `<li><a href="#" class="nav-link" data-view="admin-billing">Admin Billing</a></li>` : ''}
</ul>
<h3 class="nav-title">Quick Access</h3>
<ul class="nav-list">
@ -102,23 +108,38 @@ export class RBoxApp extends HTMLElement {
</main>
</div>
<file-upload></file-upload>
<share-modal></share-modal>
<file-preview></file-preview>
</div>
`;
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 = '<file-list></file-list>';
@ -368,7 +524,7 @@ export class RBoxApp extends HTMLElement {
break;
case 'deleted':
mainContent.innerHTML = '<deleted-files></deleted-files>';
this.attachListeners(); // Re-attach listeners for the new component
this.attachListeners();
break;
case 'starred':
mainContent.innerHTML = '<starred-items></starred-items>';
@ -382,6 +538,14 @@ export class RBoxApp extends HTMLElement {
mainContent.innerHTML = '<admin-dashboard></admin-dashboard>';
this.attachListeners();
break;
case 'billing':
mainContent.innerHTML = '<billing-dashboard></billing-dashboard>';
this.attachListeners();
break;
case 'admin-billing':
mainContent.innerHTML = '<admin-billing></admin-billing>';
this.attachListeners();
break;
}
}
}

View File

@ -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 = `
<div class="recent-files-container">
<h2>Recent Files</h2>
<div class="file-list-container">
<div class="file-list-header">
<h2>Recent Files</h2>
</div>
<p class="empty-state">No recent files found.</p>
</div>
`;
@ -34,23 +45,45 @@ export class RecentFiles extends HTMLElement {
}
this.innerHTML = `
<div class="recent-files-container">
<h2>Recent Files</h2>
<div class="file-list-container">
<div class="file-list-header">
<div class="header-left">
<h2>Recent Files</h2>
${this.files.length > 0 ? `
<div class="selection-controls">
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''} ${someSelected ? 'data-indeterminate="true"' : ''}>
<label for="select-all">
${hasSelected ? `${totalSelected} selected` : 'Select all'}
</label>
</div>
` : ''}
</div>
</div>
${hasSelected ? `
<div class="batch-actions">
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
</div>
` : ''}
<div class="file-grid">
${this.recentFiles.map(file => this.renderFile(file)).join('')}
${this.files.map(file => this.renderRecentFile(file)).join('')}
</div>
</div>
`;
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 `
<div class="file-item" data-file-id="${file.id}">
<div class="file-item ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
<input type="checkbox" class="select-item" data-type="file" data-id="${file.id}" ${isSelected ? 'checked' : ''}>
<div class="file-icon">${icon}</div>
<div class="file-name">${file.name}</div>
<div class="file-size">${size}</div>
@ -62,51 +95,24 @@ export class RecentFiles extends HTMLElement {
`;
}
getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
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' }
}));
}
}

View File

@ -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) {

View File

@ -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 = `
<div class="starred-items-container">
<h2>Starred Items</h2>
<div class="file-list-container">
<div class="file-list-header">
<h2>Starred Items</h2>
</div>
<p class="empty-state">No starred items found.</p>
</div>
`;
@ -38,71 +49,90 @@ export class StarredItems extends HTMLElement {
}
this.innerHTML = `
<div class="starred-items-container">
<h2>Starred Items</h2>
<div class="file-list-container">
<div class="file-list-header">
<div class="header-left">
<h2>Starred Items</h2>
${totalItems > 0 ? `
<div class="selection-controls">
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''} ${someSelected ? 'data-indeterminate="true"' : ''}>
<label for="select-all">
${hasSelected ? `${totalSelected} selected` : 'Select all'}
</label>
</div>
` : ''}
</div>
</div>
${hasSelected ? `
<div class="batch-actions">
<button class="button button-small" id="batch-unstar-btn">Unstar Selected</button>
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
</div>
` : ''}
<div class="file-grid">
${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('')}
</div>
</div>
`;
this.attachListeners();
this.updateIndeterminateState();
}
renderFolder(folder) {
getFolderActions(folder) {
return `<button class="action-btn star-btn" data-action="unstar-folder" data-id="${folder.id}">&#9733;</button>`;
}
getFileActions(file) {
return `
<div class="file-item folder-item" data-folder-id="${folder.id}">
<div class="file-icon">&#128193;</div>
<div class="file-name">${folder.name}</div>
<div class="file-actions-menu">
<button class="action-btn star-btn" data-action="unstar-folder" data-id="${folder.id}">&#9733;</button>
</div>
</div>
<button class="action-btn" data-action="download" data-id="${file.id}">Download</button>
<button class="action-btn star-btn" data-action="unstar-file" data-id="${file.id}">&#9733;</button>
`;
}
renderFile(file) {
const icon = this.getFileIcon(file.mime_type);
const size = this.formatFileSize(file.size);
return `
<div class="file-item" data-file-id="${file.id}">
<div class="file-icon">${icon}</div>
<div class="file-name">${file.name}</div>
<div class="file-size">${size}</div>
<div class="file-actions-menu">
<button class="action-btn" data-action="download" data-id="${file.id}">Download</button>
<button class="action-btn star-btn" data-action="unstar-file" data-id="${file.id}">&#9733;</button>
</div>
</div>
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 = `
<button class="button button-small" id="batch-unstar-btn">Unstar Selected</button>
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
`;
}
getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
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;

View File

@ -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');
});
}

View File

@ -1,4 +1,4 @@
const CACHE_NAME = 'rbox-cache-v1';
const CACHE_NAME = 'rbox-cache-v11';
const urlsToCache = [
'/',
'/static/index.html',

0
tests/__init__.py Normal file
View File

View File

View File

@ -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

View File

@ -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"))

View File

@ -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()

View File

@ -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

View File

@ -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

8
tests/conftest.py Normal file
View File

@ -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()

0
tests/e2e/__init__.py Normal file
View File

38
tests/e2e/conftest.py Normal file
View File

@ -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"

View File

@ -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

View File

@ -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 ''

View File

@ -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 ''

View File

@ -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()

View File

@ -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")