Update.
This commit is contained in:
parent
1ddb2c609d
commit
1e5a6dbd5f
152
Makefile
152
Makefile
@ -1,5 +1,153 @@
|
||||
PYTHON=".venv/bin/python3"
|
||||
RBOX=".venv/bin/rbox"
|
||||
.PHONY: help install dev test test-billing test-e2e test-coverage e2e-setup lint format clean run migrate init-db reset-db all
|
||||
|
||||
PYTHON := .venv/bin/python3
|
||||
PIP := .venv/bin/pip
|
||||
PYTEST := .venv/bin/pytest
|
||||
BLACK := .venv/bin/black
|
||||
RUFF := .venv/bin/ruff
|
||||
RBOX := .venv/bin/rbox
|
||||
|
||||
all:
|
||||
$(RBOX) --port 9004
|
||||
|
||||
help:
|
||||
@echo "RBox Development Makefile"
|
||||
@echo ""
|
||||
@echo "Available commands:"
|
||||
@echo " make all Run the application on port 9004"
|
||||
@echo " make install Install all dependencies"
|
||||
@echo " make dev Install development dependencies"
|
||||
@echo " make run Run the application locally"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make test-billing Run billing tests only"
|
||||
@echo " make test-e2e Run end-to-end tests (visible browser)"
|
||||
@echo " make test-coverage Run tests with coverage report"
|
||||
@echo " make e2e-setup Install Playwright and browsers"
|
||||
@echo " make lint Run linting checks"
|
||||
@echo " make format Format code with black"
|
||||
@echo " make migrate Run database migrations"
|
||||
@echo " make init-db Initialize database with default data"
|
||||
@echo " make reset-db Reset database (WARNING: deletes all data)"
|
||||
@echo " make clean Clean up temporary files"
|
||||
@echo " make setup Complete setup (env + install + init-db)"
|
||||
|
||||
install:
|
||||
@echo "Installing dependencies..."
|
||||
$(PIP) install -r requirements.txt
|
||||
@echo "Dependencies installed successfully"
|
||||
|
||||
dev:
|
||||
@echo "Installing development dependencies..."
|
||||
$(PIP) install pytest pytest-asyncio pytest-cov httpx black ruff stripe apscheduler dataset
|
||||
@echo "Development dependencies installed successfully"
|
||||
|
||||
run:
|
||||
@echo "Starting RBox application..."
|
||||
@echo "Access the application at http://localhost:8000"
|
||||
$(PYTHON) -m rbox.main
|
||||
|
||||
test:
|
||||
@echo "Running all tests..."
|
||||
$(PYTEST) tests/ -v
|
||||
|
||||
test-billing:
|
||||
@echo "Running billing tests..."
|
||||
$(PYTEST) tests/billing/ -v
|
||||
|
||||
test-e2e:
|
||||
@echo "Running end-to-end tests with visible browser..."
|
||||
@echo "Make sure the application is running (make run in another terminal)"
|
||||
$(PYTEST) tests/e2e/ -v -s --tb=short
|
||||
|
||||
e2e-setup:
|
||||
@echo "Installing Playwright and browsers..."
|
||||
$(PIP) install playwright
|
||||
.venv/bin/playwright install chromium
|
||||
@echo "Playwright setup complete"
|
||||
|
||||
test-coverage:
|
||||
@echo "Running tests with coverage..."
|
||||
$(PYTEST) tests/ -v --cov=rbox --cov-report=html --cov-report=term
|
||||
@echo "Coverage report generated in htmlcov/index.html"
|
||||
|
||||
lint:
|
||||
@echo "Running linting checks..."
|
||||
$(RUFF) check rbox/
|
||||
@echo "Linting complete"
|
||||
|
||||
format:
|
||||
@echo "Formatting code..."
|
||||
$(BLACK) rbox/ tests/
|
||||
@echo "Code formatting complete"
|
||||
|
||||
migrate:
|
||||
@echo "Running database migrations..."
|
||||
@echo "Tortoise ORM auto-generates schemas on startup"
|
||||
$(PYTHON) -c "from rbox.main import app; print('Database schema will be created on first run')"
|
||||
|
||||
init-db:
|
||||
@echo "Initializing database with default data..."
|
||||
$(PYTHON) -c "import asyncio; \
|
||||
from tortoise import Tortoise; \
|
||||
from rbox.settings import settings; \
|
||||
from rbox.billing.models import PricingConfig; \
|
||||
from decimal import Decimal; \
|
||||
async def init(): \
|
||||
await Tortoise.init(db_url=settings.DATABASE_URL, modules={'models': ['rbox.models', 'rbox.billing.models']}); \
|
||||
await Tortoise.generate_schemas(); \
|
||||
count = await PricingConfig.all().count(); \
|
||||
if count == 0: \
|
||||
await PricingConfig.create(config_key='storage_per_gb_month', config_value=Decimal('0.0045'), description='Storage cost per GB per month', unit='per_gb_month'); \
|
||||
await PricingConfig.create(config_key='bandwidth_egress_per_gb', config_value=Decimal('0.009'), description='Bandwidth egress cost per GB', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='bandwidth_ingress_per_gb', config_value=Decimal('0.0'), description='Bandwidth ingress cost per GB (free)', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='free_tier_storage_gb', config_value=Decimal('15'), description='Free tier storage in GB', unit='gb'); \
|
||||
await PricingConfig.create(config_key='free_tier_bandwidth_gb', config_value=Decimal('15'), description='Free tier bandwidth in GB per month', unit='gb'); \
|
||||
await PricingConfig.create(config_key='tax_rate_default', config_value=Decimal('0.0'), description='Default tax rate (0 = no tax)', unit='percentage'); \
|
||||
print('Default pricing configuration created'); \
|
||||
else: \
|
||||
print('Pricing configuration already exists'); \
|
||||
await Tortoise.close_connections(); \
|
||||
asyncio.run(init())"
|
||||
@echo "Database initialized successfully"
|
||||
|
||||
reset-db:
|
||||
@echo "WARNING: This will delete all data!"
|
||||
@read -p "Are you sure? (yes/no): " confirm && [ "$$confirm" = "yes" ] || exit 1
|
||||
@rm -f rbox.db app/rbox.db storage/rbox.db
|
||||
@echo "Database reset complete. Run 'make init-db' to reinitialize"
|
||||
|
||||
clean:
|
||||
@echo "Cleaning up temporary files..."
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete
|
||||
find . -type f -name "*.pyo" -delete
|
||||
find . -type f -name "*.pyd" -delete
|
||||
find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
rm -rf htmlcov/
|
||||
rm -rf .coverage
|
||||
@echo "Cleanup complete"
|
||||
|
||||
setup-env:
|
||||
@echo "Setting up environment file..."
|
||||
@if [ ! -f .env ]; then \
|
||||
cp .env.example .env 2>/dev/null || \
|
||||
echo "DATABASE_URL=sqlite:///app/rbox.db\nSECRET_KEY=$$(openssl rand -hex 32)\nSTRIPE_SECRET_KEY=\nSTRIPE_PUBLISHABLE_KEY=\nSTRIPE_WEBHOOK_SECRET=" > .env; \
|
||||
echo ".env file created. Please update with your configuration."; \
|
||||
else \
|
||||
echo ".env file already exists"; \
|
||||
fi
|
||||
|
||||
setup: setup-env install dev init-db
|
||||
@echo ""
|
||||
@echo "Setup complete!"
|
||||
@echo "Next steps:"
|
||||
@echo " 1. Update .env with your configuration (especially Stripe keys)"
|
||||
@echo " 2. Run 'make run' or 'make all' to start the application"
|
||||
@echo " 3. Access the application at http://localhost:8000"
|
||||
|
||||
docs:
|
||||
@echo "Generating API documentation..."
|
||||
@echo "API documentation available at http://localhost:8000/docs when running"
|
||||
@echo "ReDoc available at http://localhost:8000/redoc when running"
|
||||
|
||||
@ -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
0
rbox/billing/__init__.py
Normal file
143
rbox/billing/invoice_generator.py
Normal file
143
rbox/billing/invoice_generator.py
Normal 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
139
rbox/billing/models.py
Normal 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
57
rbox/billing/scheduler.py
Normal 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()
|
||||
105
rbox/billing/stripe_client.py
Normal file
105
rbox/billing/stripe_client.py
Normal 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)
|
||||
142
rbox/billing/usage_tracker.py
Normal file
142
rbox/billing/usage_tracker.py
Normal 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)
|
||||
}
|
||||
59
rbox/main.py
59
rbox/main.py
@ -1,23 +1,50 @@
|
||||
import argparse
|
||||
import uvicorn
|
||||
import logging # Import logging
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request, status, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from tortoise.contrib.fastapi import register_tortoise
|
||||
from .settings import settings
|
||||
from .routers import auth, users, folders, files, shares, search, admin, starred
|
||||
from .routers import auth, users, folders, files, shares, search, admin, starred, billing, admin_billing
|
||||
from . import webdav
|
||||
from .schemas import ErrorResponse # Import ErrorResponse
|
||||
from .schemas import ErrorResponse
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Starting up...")
|
||||
logger.info("Database connected.")
|
||||
from .billing.scheduler import start_scheduler
|
||||
from .billing.models import PricingConfig
|
||||
start_scheduler()
|
||||
logger.info("Billing scheduler started")
|
||||
pricing_count = await PricingConfig.all().count()
|
||||
if pricing_count == 0:
|
||||
from decimal import Decimal
|
||||
await PricingConfig.create(config_key='storage_per_gb_month', config_value=Decimal('0.0045'), description='Storage cost per GB per month', unit='per_gb_month')
|
||||
await PricingConfig.create(config_key='bandwidth_egress_per_gb', config_value=Decimal('0.009'), description='Bandwidth egress cost per GB', unit='per_gb')
|
||||
await PricingConfig.create(config_key='bandwidth_ingress_per_gb', config_value=Decimal('0.0'), description='Bandwidth ingress cost per GB (free)', unit='per_gb')
|
||||
await PricingConfig.create(config_key='free_tier_storage_gb', config_value=Decimal('15'), description='Free tier storage in GB', unit='gb')
|
||||
await PricingConfig.create(config_key='free_tier_bandwidth_gb', config_value=Decimal('15'), description='Free tier bandwidth in GB per month', unit='gb')
|
||||
await PricingConfig.create(config_key='tax_rate_default', config_value=Decimal('0.0'), description='Default tax rate (0 = no tax)', unit='percentage')
|
||||
logger.info("Default pricing configuration initialized")
|
||||
|
||||
yield
|
||||
|
||||
from .billing.scheduler import stop_scheduler
|
||||
stop_scheduler()
|
||||
logger.info("Billing scheduler stopped")
|
||||
print("Shutting down...")
|
||||
|
||||
app = FastAPI(
|
||||
title="RBox Cloud Storage",
|
||||
description="A self-hosted cloud storage web application",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
@ -26,17 +53,25 @@ app.include_router(folders.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(shares.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(admin.router) # Include the admin router
|
||||
app.include_router(starred.router) # Include the starred router
|
||||
app.include_router(admin.router)
|
||||
app.include_router(starred.router)
|
||||
app.include_router(billing.router)
|
||||
app.include_router(admin_billing.router)
|
||||
app.include_router(webdav.router)
|
||||
|
||||
# Mount static files
|
||||
from .middleware.usage_tracking import UsageTrackingMiddleware
|
||||
|
||||
app.add_middleware(UsageTrackingMiddleware)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
register_tortoise(
|
||||
app,
|
||||
db_url=settings.DATABASE_URL,
|
||||
modules={"models": ["rbox.models"]},
|
||||
modules={
|
||||
"models": ["rbox.models"],
|
||||
"billing": ["rbox.billing.models"]
|
||||
},
|
||||
generate_schemas=True,
|
||||
add_exception_handlers=True,
|
||||
)
|
||||
@ -49,14 +84,6 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
content=ErrorResponse(code=exc.status_code, message=exc.detail).dict(),
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
logger.info("Starting up...")
|
||||
logger.info("Database connected.")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
print("Shutting down...")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse) # Change response_class to HTMLResponse
|
||||
async def read_root():
|
||||
|
||||
0
rbox/middleware/__init__.py
Normal file
0
rbox/middleware/__init__.py
Normal file
32
rbox/middleware/usage_tracking.py
Normal file
32
rbox/middleware/usage_tracking.py
Normal 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
|
||||
116
rbox/routers/admin_billing.py
Normal file
116
rbox/routers/admin_billing.py
Normal 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
|
||||
}
|
||||
@ -65,16 +65,16 @@ async def register_user(user_in: UserCreate):
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
async def login_for_access_token(user_login: UserLoginWith2FA):
|
||||
auth_result = await authenticate_user(user_login.username, user_login.password, user_login.two_factor_code)
|
||||
|
||||
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
auth_result = await authenticate_user(form_data.username, form_data.password, None)
|
||||
|
||||
if not auth_result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password or 2FA code",
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
user = auth_result["user"]
|
||||
if auth_result["2fa_required"]:
|
||||
raise HTTPException(
|
||||
|
||||
312
rbox/routers/billing.py
Normal file
312
rbox/routers/billing.py
Normal 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
|
||||
]
|
||||
@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File as FastAPIFile, HTTPException, status, Response
|
||||
from fastapi import APIRouter, Depends, UploadFile, File as FastAPIFile, HTTPException, status, Response, Form
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import List, Optional
|
||||
import mimetypes
|
||||
@ -36,11 +36,13 @@ class BatchFileOperation(BaseModel):
|
||||
class BatchMoveCopyPayload(BaseModel):
|
||||
target_folder_id: Optional[int] = None
|
||||
|
||||
@router.post("/upload/{folder_id}", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
||||
class FileContentUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
@router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_file(
|
||||
file: UploadFile = FastAPIFile(...),
|
||||
folder_id: Optional[int] = None,
|
||||
folder_id: Optional[int] = Form(None),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
if folder_id:
|
||||
@ -113,9 +115,6 @@ async def download_file(file_id: int, current_user: User = Depends(get_current_u
|
||||
await db_file.save()
|
||||
|
||||
try:
|
||||
# file_content_generator = storage_manager.get_file(current_user.id, db_file.path)
|
||||
|
||||
# FastAPI's StreamingResponse expects an async generator
|
||||
async def file_iterator():
|
||||
async for chunk in storage_manager.get_file(current_user.id, db_file.path):
|
||||
yield chunk
|
||||
@ -133,11 +132,13 @@ async def delete_file(file_id: int, current_user: User = Depends(get_current_use
|
||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||
if not db_file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
|
||||
db_file.is_deleted = True
|
||||
db_file.deleted_at = datetime.now()
|
||||
await db_file.save()
|
||||
|
||||
await delete_thumbnail(db_file.id)
|
||||
|
||||
return
|
||||
|
||||
@router.post("/{file_id}/move", response_model=FileOut)
|
||||
@ -324,74 +325,158 @@ async def restore_file(file_id: int, current_user: User = Depends(get_current_us
|
||||
await log_activity(user=current_user, action="file_restored", target_type="file", target_id=file_id)
|
||||
return await FileOut.from_tortoise_orm(db_file)
|
||||
|
||||
@router.post("/batch", response_model=List[FileOut])
|
||||
class BatchOperationResult(BaseModel):
|
||||
succeeded: List[FileOut]
|
||||
failed: List[dict]
|
||||
|
||||
@router.post("/batch")
|
||||
async def batch_file_operations(
|
||||
batch_operation: BatchFileOperation,
|
||||
payload: Optional[BatchMoveCopyPayload] = None,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
if batch_operation.operation not in ["delete", "star", "unstar", "move", "copy"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid operation: {batch_operation.operation}")
|
||||
|
||||
updated_files = []
|
||||
failed_operations = []
|
||||
|
||||
for file_id in batch_operation.file_ids:
|
||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||
if not db_file:
|
||||
# Skip if file not found or not owned by user
|
||||
continue
|
||||
try:
|
||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||
if not db_file:
|
||||
failed_operations.append({"file_id": file_id, "reason": "File not found or not owned by user"})
|
||||
continue
|
||||
|
||||
if batch_operation.operation == "delete":
|
||||
db_file.is_deleted = True
|
||||
db_file.deleted_at = datetime.now()
|
||||
await db_file.save()
|
||||
await log_activity(user=current_user, action="file_deleted_batch", target_type="file", target_id=file_id)
|
||||
updated_files.append(db_file)
|
||||
elif batch_operation.operation == "star":
|
||||
db_file.is_starred = True
|
||||
await db_file.save()
|
||||
await log_activity(user=current_user, action="file_starred_batch", target_type="file", target_id=file_id)
|
||||
updated_files.append(db_file)
|
||||
elif batch_operation.operation == "unstar":
|
||||
db_file.is_starred = False
|
||||
await db_file.save()
|
||||
await log_activity(user=current_user, action="file_unstarred_batch", target_type="file", target_id=file_id)
|
||||
updated_files.append(db_file)
|
||||
elif batch_operation.operation == "move" and payload and payload.target_folder_id is not None:
|
||||
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
||||
if not target_folder:
|
||||
continue # Skip if target folder not found
|
||||
if batch_operation.operation == "delete":
|
||||
db_file.is_deleted = True
|
||||
db_file.deleted_at = datetime.now()
|
||||
await db_file.save()
|
||||
await delete_thumbnail(db_file.id)
|
||||
await log_activity(user=current_user, action="file_deleted_batch", target_type="file", target_id=file_id)
|
||||
updated_files.append(db_file)
|
||||
elif batch_operation.operation == "star":
|
||||
db_file.is_starred = True
|
||||
await db_file.save()
|
||||
await log_activity(user=current_user, action="file_starred_batch", target_type="file", target_id=file_id)
|
||||
updated_files.append(db_file)
|
||||
elif batch_operation.operation == "unstar":
|
||||
db_file.is_starred = False
|
||||
await db_file.save()
|
||||
await log_activity(user=current_user, action="file_unstarred_batch", target_type="file", target_id=file_id)
|
||||
updated_files.append(db_file)
|
||||
elif batch_operation.operation == "move":
|
||||
if not payload or payload.target_folder_id is None:
|
||||
failed_operations.append({"file_id": file_id, "reason": "Target folder not specified"})
|
||||
continue
|
||||
|
||||
existing_file = await File.get_or_none(
|
||||
name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False
|
||||
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
||||
if not target_folder:
|
||||
failed_operations.append({"file_id": file_id, "reason": "Target folder not found"})
|
||||
continue
|
||||
|
||||
existing_file = await File.get_or_none(
|
||||
name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False
|
||||
)
|
||||
if existing_file and existing_file.id != file_id:
|
||||
failed_operations.append({"file_id": file_id, "reason": "File with same name exists in target folder"})
|
||||
continue
|
||||
|
||||
db_file.parent = target_folder
|
||||
await db_file.save()
|
||||
await log_activity(user=current_user, action="file_moved_batch", target_type="file", target_id=file_id)
|
||||
updated_files.append(db_file)
|
||||
elif batch_operation.operation == "copy":
|
||||
if not payload or payload.target_folder_id is None:
|
||||
failed_operations.append({"file_id": file_id, "reason": "Target folder not specified"})
|
||||
continue
|
||||
|
||||
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
||||
if not target_folder:
|
||||
failed_operations.append({"file_id": file_id, "reason": "Target folder not found"})
|
||||
continue
|
||||
|
||||
base_name = db_file.name
|
||||
name_parts = os.path.splitext(base_name)
|
||||
counter = 1
|
||||
new_name = base_name
|
||||
|
||||
while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False):
|
||||
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
|
||||
counter += 1
|
||||
|
||||
new_file = await File.create(
|
||||
name=new_name,
|
||||
path=db_file.path,
|
||||
size=db_file.size,
|
||||
mime_type=db_file.mime_type,
|
||||
file_hash=db_file.file_hash,
|
||||
owner=current_user,
|
||||
parent=target_folder
|
||||
)
|
||||
await log_activity(user=current_user, action="file_copied_batch", target_type="file", target_id=new_file.id)
|
||||
updated_files.append(new_file)
|
||||
except Exception as e:
|
||||
failed_operations.append({"file_id": file_id, "reason": str(e)})
|
||||
|
||||
return {
|
||||
"succeeded": [await FileOut.from_tortoise_orm(f) for f in updated_files],
|
||||
"failed": failed_operations
|
||||
}
|
||||
|
||||
@router.put("/{file_id}/content", response_model=FileOut)
|
||||
async def update_file_content(
|
||||
file_id: int,
|
||||
payload: FileContentUpdate,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||
if not db_file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
if not db_file.mime_type or not db_file.mime_type.startswith('text/'):
|
||||
editableExtensions = [
|
||||
'txt', 'md', 'log', 'json', 'js', 'py', 'html', 'css',
|
||||
'xml', 'yaml', 'yml', 'sh', 'bat', 'ini', 'conf', 'cfg'
|
||||
]
|
||||
file_extension = os.path.splitext(db_file.name)[1][1:].lower()
|
||||
if file_extension not in editableExtensions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File type is not editable"
|
||||
)
|
||||
if existing_file and existing_file.id != file_id:
|
||||
continue # Skip if file with same name exists
|
||||
|
||||
db_file.parent = target_folder
|
||||
await db_file.save()
|
||||
await log_activity(user=current_user, action="file_moved_batch", target_type="file", target_id=file_id)
|
||||
updated_files.append(db_file)
|
||||
elif batch_operation.operation == "copy" and payload and payload.target_folder_id is not None:
|
||||
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
||||
if not target_folder:
|
||||
continue # Skip if target folder not found
|
||||
content_bytes = payload.content.encode('utf-8')
|
||||
new_size = len(content_bytes)
|
||||
size_diff = new_size - db_file.size
|
||||
|
||||
base_name = db_file.name
|
||||
name_parts = os.path.splitext(base_name)
|
||||
counter = 1
|
||||
new_name = base_name
|
||||
if current_user.used_storage_bytes + size_diff > current_user.storage_quota_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
||||
detail="Storage quota exceeded"
|
||||
)
|
||||
|
||||
while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False):
|
||||
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
|
||||
counter += 1
|
||||
new_hash = hashlib.sha256(content_bytes).hexdigest()
|
||||
file_extension = os.path.splitext(db_file.name)[1]
|
||||
new_storage_path = f"{new_hash}{file_extension}"
|
||||
|
||||
new_file = await File.create(
|
||||
name=new_name,
|
||||
path=db_file.path,
|
||||
size=db_file.size,
|
||||
mime_type=db_file.mime_type,
|
||||
file_hash=db_file.file_hash,
|
||||
owner=current_user,
|
||||
parent=target_folder
|
||||
)
|
||||
await log_activity(user=current_user, action="file_copied_batch", target_type="file", target_id=new_file.id)
|
||||
updated_files.append(new_file)
|
||||
|
||||
return [await FileOut.from_tortoise_orm(f) for f in updated_files]
|
||||
await storage_manager.save_file(current_user.id, new_storage_path, content_bytes)
|
||||
|
||||
if new_storage_path != db_file.path:
|
||||
try:
|
||||
await storage_manager.delete_file(current_user.id, db_file.path)
|
||||
except:
|
||||
pass
|
||||
|
||||
db_file.path = new_storage_path
|
||||
db_file.size = new_size
|
||||
db_file.file_hash = new_hash
|
||||
db_file.updated_at = datetime.utcnow()
|
||||
await db_file.save()
|
||||
|
||||
current_user.used_storage_bytes += size_diff
|
||||
await current_user.save()
|
||||
|
||||
await log_activity(user=current_user, action="file_updated", target_type="file", target_id=file_id)
|
||||
|
||||
return await FileOut.from_tortoise_orm(db_file)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import Optional, List
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..models import User, File, Folder, Share
|
||||
from ..schemas import ShareCreate, ShareOut
|
||||
from ..schemas import ShareCreate, ShareOut, FileOut, FolderOut
|
||||
from ..auth import get_password_hash, verify_password
|
||||
from ..storage import storage_manager
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/shares",
|
||||
@ -99,17 +101,72 @@ async def access_shared_content(share_token: str, password: Optional[str] = None
|
||||
share = await Share.get_or_none(token=share_token)
|
||||
if not share:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found")
|
||||
|
||||
|
||||
if share.expires_at and share.expires_at < datetime.utcnow():
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired")
|
||||
|
||||
if share.password_protected:
|
||||
if not password or not verify_password(password, share.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password")
|
||||
|
||||
# TODO: Return actual content or a link to it based on permission_level
|
||||
# For now, just indicate successful access
|
||||
return {"message": "Access granted", "permission_level": share.permission_level}
|
||||
|
||||
result = {"message": "Access granted", "permission_level": share.permission_level}
|
||||
|
||||
if share.file_id:
|
||||
file = await File.get_or_none(id=share.file_id, is_deleted=False)
|
||||
if not file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
result["file"] = await FileOut.from_tortoise_orm(file)
|
||||
result["type"] = "file"
|
||||
elif share.folder_id:
|
||||
folder = await Folder.get_or_none(id=share.folder_id, is_deleted=False)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
||||
result["folder"] = await FolderOut.from_tortoise_orm(folder)
|
||||
result["type"] = "folder"
|
||||
|
||||
files = await File.filter(parent=folder, is_deleted=False)
|
||||
subfolders = await Folder.filter(parent=folder, is_deleted=False)
|
||||
result["files"] = [await FileOut.from_tortoise_orm(f) for f in files]
|
||||
result["folders"] = [await FolderOut.from_tortoise_orm(f) for f in subfolders]
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/{share_token}/download")
|
||||
async def download_shared_file(share_token: str, password: Optional[str] = None):
|
||||
share = await Share.get_or_none(token=share_token)
|
||||
if not share:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found")
|
||||
|
||||
if share.expires_at and share.expires_at < datetime.utcnow():
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired")
|
||||
|
||||
if share.password_protected:
|
||||
if not password or not verify_password(password, share.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password")
|
||||
|
||||
if not share.file_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This share is not for a file")
|
||||
|
||||
file = await File.get_or_none(id=share.file_id, is_deleted=False)
|
||||
if not file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
owner = await User.get(id=file.owner_id)
|
||||
|
||||
try:
|
||||
async def file_iterator():
|
||||
async for chunk in storage_manager.get_file(owner.id, file.path):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
content=file_iterator(),
|
||||
media_type=file.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{file.name}"'
|
||||
}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="File not found in storage")
|
||||
|
||||
@router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_share_link(share_id: int, current_user: User = Depends(get_current_user)):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
371
static/css/billing.css
Normal 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;
|
||||
}
|
||||
80
static/css/code-editor-view.css
Normal file
80
static/css/code-editor-view.css
Normal 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;
|
||||
}
|
||||
145
static/css/file-upload-view.css
Normal file
145
static/css/file-upload-view.css
Normal 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;
|
||||
}
|
||||
@ -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 |
BIN
static/icons/icon-256x256.png
Normal file
BIN
static/icons/icon-256x256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
static/icons/icon-384x384.png
Normal file
BIN
static/icons/icon-384x384.png
Normal file
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 |
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
193
static/js/components/admin-billing.js
Normal file
193
static/js/components/admin-billing.js
Normal 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;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
268
static/js/components/base-file-list.js
Normal file
268
static/js/components/base-file-list.js
Normal 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 '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
renderFolder(folder) {
|
||||
const isSelected = this.selectedFolders.has(folder.id);
|
||||
const starIcon = folder.is_starred ? '★' : '☆';
|
||||
const starAction = folder.is_starred ? 'unstar-folder' : 'star-folder';
|
||||
const actions = this.getFolderActions(folder);
|
||||
|
||||
return `
|
||||
<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">📁</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 ? '★' : '☆';
|
||||
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) {
|
||||
}
|
||||
}
|
||||
309
static/js/components/billing-dashboard.js
Normal file
309
static/js/components/billing-dashboard.js
Normal 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;
|
||||
153
static/js/components/code-editor-view.js
Normal file
153
static/js/components/code-editor-view.js
Normal 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 };
|
||||
@ -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 '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
createBatchActionsBar() {
|
||||
const container = this.querySelector('.file-list-container');
|
||||
const header = container.querySelector('.file-list-header');
|
||||
const batchBar = document.createElement('div');
|
||||
batchBar.className = 'batch-actions';
|
||||
batchBar.innerHTML = `
|
||||
<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' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 '📄';
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@ -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';
|
||||
|
||||
187
static/js/components/file-upload-view.js
Normal file
187
static/js/components/file-upload-view.js
Normal 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);
|
||||
@ -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">
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.querySelectorAll('.action-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const id = parseInt(btn.dataset.id);
|
||||
if (action === 'download') {
|
||||
await this.handleDownload(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleDownload(fileId) {
|
||||
async handleAction(action, id) {
|
||||
try {
|
||||
const blob = await api.downloadFile(fileId);
|
||||
const file = this.recentFiles.find(f => f.id === fileId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'File downloaded successfully!', type: 'success' }
|
||||
}));
|
||||
if (action === 'download') {
|
||||
const blob = await api.downloadFile(id);
|
||||
const file = this.files.find(f => f.id === id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'File downloaded successfully!', type: 'success' }
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to download file: ' + error.message, type: 'error' }
|
||||
detail: { message: 'Action failed: ' + error.message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}">★</button>`;
|
||||
}
|
||||
|
||||
getFileActions(file) {
|
||||
return `
|
||||
<div class="file-item folder-item" data-folder-id="${folder.id}">
|
||||
<div class="file-icon">📁</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}">★</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}">★</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}">★</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 '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
header.insertAdjacentElement('afterend', batchBar);
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.querySelectorAll('.action-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const id = parseInt(btn.dataset.id);
|
||||
await this.handleAction(action, id);
|
||||
});
|
||||
});
|
||||
const batchUnstarBtn = this.querySelector('#batch-unstar-btn');
|
||||
if (batchUnstarBtn) {
|
||||
batchUnstarBtn.addEventListener('click', () => this.handleBatchUnstar());
|
||||
}
|
||||
}
|
||||
|
||||
async handleBatchUnstar() {
|
||||
const totalSelected = this.selectedFiles.size + this.selectedFolders.size;
|
||||
if (totalSelected === 0) return;
|
||||
|
||||
if (!confirm(`Unstar ${totalSelected} items?`)) return;
|
||||
|
||||
try {
|
||||
for (const fileId of this.selectedFiles) {
|
||||
await api.unstarFile(fileId);
|
||||
}
|
||||
for (const folderId of this.selectedFolders) {
|
||||
await api.unstarFolder(folderId);
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Items unstarred successfully!', type: 'success' }
|
||||
}));
|
||||
await this.loadStarredItems();
|
||||
} catch (error) {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to unstar items: ' + error.message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async handleAction(action, id) {
|
||||
@ -110,7 +140,7 @@ export class StarredItems extends HTMLElement {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
const blob = await api.downloadFile(id);
|
||||
const file = this.starredFiles.find(f => f.id === id);
|
||||
const file = this.files.find(f => f.id === id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
0
tests/__init__.py
Normal file
0
tests/billing/__init__.py
Normal file
0
tests/billing/__init__.py
Normal file
256
tests/billing/test_api_endpoints.py
Normal file
256
tests/billing/test_api_endpoints.py
Normal 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
|
||||
195
tests/billing/test_invoice_generator.py
Normal file
195
tests/billing/test_invoice_generator.py
Normal 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"))
|
||||
188
tests/billing/test_models.py
Normal file
188
tests/billing/test_models.py
Normal 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()
|
||||
77
tests/billing/test_simple.py
Normal file
77
tests/billing/test_simple.py
Normal 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
|
||||
175
tests/billing/test_usage_tracker.py
Normal file
175
tests/billing/test_usage_tracker.py
Normal 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
8
tests/conftest.py
Normal 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
0
tests/e2e/__init__.py
Normal file
38
tests/e2e/conftest.py
Normal file
38
tests/e2e/conftest.py
Normal 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"
|
||||
199
tests/e2e/test_billing_admin_flow.py
Normal file
199
tests/e2e/test_billing_admin_flow.py
Normal 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
|
||||
183
tests/e2e/test_billing_api_flow.py
Normal file
183
tests/e2e/test_billing_api_flow.py
Normal 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 ''
|
||||
264
tests/e2e/test_billing_integration_flow.py
Normal file
264
tests/e2e/test_billing_integration_flow.py
Normal 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 ''
|
||||
264
tests/e2e/test_billing_user_flow.py
Normal file
264
tests/e2e/test_billing_user_flow.py
Normal 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()
|
||||
98
tests/e2e/test_structure_validation.py
Normal file
98
tests/e2e/test_structure_validation.py
Normal 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")
|
||||
Loading…
Reference in New Issue
Block a user