19 Commits

Author SHA1 Message Date
d0d436d116 Create timespent.py 2025-08-26 17:57:33 +02:00
36da0717a2 better launch management caching 2025-08-26 09:02:29 +02:00
8a622334d0 handle 502 errors on launch 2025-08-26 08:52:54 +02:00
b1da4977d3 Update instances.html 2025-06-26 15:24:40 +02:00
9b85f3bb8d started implementing stripe 2025-06-26 15:15:16 +02:00
3a0659b63b added payment links to prices 2025-06-25 17:14:03 +02:00
5b598f2966 finalized update feature 2025-06-25 15:27:49 +02:00
77032062a1 better update? 2025-06-25 15:07:35 +02:00
81675af837 Update launch_progress.js 2025-06-25 14:58:26 +02:00
0a2cddf122 Update start and better volume names 2025-06-25 14:53:32 +02:00
56d94a06ce Better stack name 2025-06-25 14:21:22 +02:00
de3880e880 fixed help articles 2025-06-25 13:34:43 +02:00
0466b11c71 delete functionality on instances page 2025-06-25 11:58:37 +02:00
e519dc3a8b delete old files 2025-06-25 11:39:14 +02:00
ac9f002365 color system on public pages 2025-06-25 11:38:12 +02:00
8de74827f2 split js and css on instance detail 2025-06-25 11:16:10 +02:00
81552bc5ec split css and js on instances 2025-06-25 11:09:56 +02:00
490bc05a9e better delete modal 2025-06-25 10:40:05 +02:00
cc699506d3 update docker file for version 2025-06-24 15:42:54 +02:00
56 changed files with 7417 additions and 4249 deletions

View File

@@ -1,274 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "Nginx Proxy Manager API",
"version": "2.x.x"
},
"servers": [
{
"url": "http://127.0.0.1:81/api"
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
},
"paths": {
"/": {
"get": {
"$ref": "./paths/get.json"
}
},
"/audit-log": {
"get": {
"$ref": "./paths/audit-log/get.json"
}
},
"/nginx/access-lists": {
"get": {
"$ref": "./paths/nginx/access-lists/get.json"
},
"post": {
"$ref": "./paths/nginx/access-lists/post.json"
}
},
"/nginx/access-lists/{listID}": {
"get": {
"$ref": "./paths/nginx/access-lists/listID/get.json"
},
"put": {
"$ref": "./paths/nginx/access-lists/listID/put.json"
},
"delete": {
"$ref": "./paths/nginx/access-lists/listID/delete.json"
}
},
"/nginx/certificates": {
"get": {
"$ref": "./paths/nginx/certificates/get.json"
},
"post": {
"$ref": "./paths/nginx/certificates/post.json"
}
},
"/nginx/certificates/validate": {
"post": {
"$ref": "./paths/nginx/certificates/validate/post.json"
}
},
"/nginx/certificates/test-http": {
"get": {
"$ref": "./paths/nginx/certificates/test-http/get.json"
}
},
"/nginx/certificates/{certID}": {
"get": {
"$ref": "./paths/nginx/certificates/certID/get.json"
},
"delete": {
"$ref": "./paths/nginx/certificates/certID/delete.json"
}
},
"/nginx/certificates/{certID}/download": {
"get": {
"$ref": "./paths/nginx/certificates/certID/download/get.json"
}
},
"/nginx/certificates/{certID}/renew": {
"post": {
"$ref": "./paths/nginx/certificates/certID/renew/post.json"
}
},
"/nginx/certificates/{certID}/upload": {
"post": {
"$ref": "./paths/nginx/certificates/certID/upload/post.json"
}
},
"/nginx/proxy-hosts": {
"get": {
"$ref": "./paths/nginx/proxy-hosts/get.json"
},
"post": {
"$ref": "./paths/nginx/proxy-hosts/post.json"
}
},
"/nginx/proxy-hosts/{hostID}": {
"get": {
"$ref": "./paths/nginx/proxy-hosts/hostID/get.json"
},
"put": {
"$ref": "./paths/nginx/proxy-hosts/hostID/put.json"
},
"delete": {
"$ref": "./paths/nginx/proxy-hosts/hostID/delete.json"
}
},
"/nginx/proxy-hosts/{hostID}/enable": {
"post": {
"$ref": "./paths/nginx/proxy-hosts/hostID/enable/post.json"
}
},
"/nginx/proxy-hosts/{hostID}/disable": {
"post": {
"$ref": "./paths/nginx/proxy-hosts/hostID/disable/post.json"
}
},
"/nginx/redirection-hosts": {
"get": {
"$ref": "./paths/nginx/redirection-hosts/get.json"
},
"post": {
"$ref": "./paths/nginx/redirection-hosts/post.json"
}
},
"/nginx/redirection-hosts/{hostID}": {
"get": {
"$ref": "./paths/nginx/redirection-hosts/hostID/get.json"
},
"put": {
"$ref": "./paths/nginx/redirection-hosts/hostID/put.json"
},
"delete": {
"$ref": "./paths/nginx/redirection-hosts/hostID/delete.json"
}
},
"/nginx/redirection-hosts/{hostID}/enable": {
"post": {
"$ref": "./paths/nginx/redirection-hosts/hostID/enable/post.json"
}
},
"/nginx/redirection-hosts/{hostID}/disable": {
"post": {
"$ref": "./paths/nginx/redirection-hosts/hostID/disable/post.json"
}
},
"/nginx/dead-hosts": {
"get": {
"$ref": "./paths/nginx/dead-hosts/get.json"
},
"post": {
"$ref": "./paths/nginx/dead-hosts/post.json"
}
},
"/nginx/dead-hosts/{hostID}": {
"get": {
"$ref": "./paths/nginx/dead-hosts/hostID/get.json"
},
"put": {
"$ref": "./paths/nginx/dead-hosts/hostID/put.json"
},
"delete": {
"$ref": "./paths/nginx/dead-hosts/hostID/delete.json"
}
},
"/nginx/dead-hosts/{hostID}/enable": {
"post": {
"$ref": "./paths/nginx/dead-hosts/hostID/enable/post.json"
}
},
"/nginx/dead-hosts/{hostID}/disable": {
"post": {
"$ref": "./paths/nginx/dead-hosts/hostID/disable/post.json"
}
},
"/nginx/streams": {
"get": {
"$ref": "./paths/nginx/streams/get.json"
},
"post": {
"$ref": "./paths/nginx/streams/post.json"
}
},
"/nginx/streams/{streamID}": {
"get": {
"$ref": "./paths/nginx/streams/streamID/get.json"
},
"put": {
"$ref": "./paths/nginx/streams/streamID/put.json"
},
"delete": {
"$ref": "./paths/nginx/streams/streamID/delete.json"
}
},
"/nginx/streams/{streamID}/enable": {
"post": {
"$ref": "./paths/nginx/streams/streamID/enable/post.json"
}
},
"/nginx/streams/{streamID}/disable": {
"post": {
"$ref": "./paths/nginx/streams/streamID/disable/post.json"
}
},
"/reports/hosts": {
"get": {
"$ref": "./paths/reports/hosts/get.json"
}
},
"/schema": {
"get": {
"$ref": "./paths/schema/get.json"
}
},
"/settings": {
"get": {
"$ref": "./paths/settings/get.json"
}
},
"/settings/{settingID}": {
"get": {
"$ref": "./paths/settings/settingID/get.json"
},
"put": {
"$ref": "./paths/settings/settingID/put.json"
}
},
"/tokens": {
"get": {
"$ref": "./paths/tokens/get.json"
},
"post": {
"$ref": "./paths/tokens/post.json"
}
},
"/users": {
"get": {
"$ref": "./paths/users/get.json"
},
"post": {
"$ref": "./paths/users/post.json"
}
},
"/users/{userID}": {
"get": {
"$ref": "./paths/users/userID/get.json"
},
"put": {
"$ref": "./paths/users/userID/put.json"
},
"delete": {
"$ref": "./paths/users/userID/delete.json"
}
},
"/users/{userID}/auth": {
"put": {
"$ref": "./paths/users/userID/auth/put.json"
}
},
"/users/{userID}/permissions": {
"put": {
"$ref": "./paths/users/userID/permissions/put.json"
}
},
"/users/{userID}/login": {
"post": {
"$ref": "./paths/users/userID/login/post.json"
}
}
}
}

View File

@@ -1,246 +0,0 @@
# Pricing Configuration Feature
This document describes the new configurable pricing feature that allows MASTER instances to manage pricing plans through the admin interface.
## Overview
The pricing configuration feature allows administrators on MASTER instances to:
- Create, edit, and delete pricing plans
- Configure plan features, prices, and settings
- Set resource quotas for rooms, conversations, storage, and users
- Mark plans as "Most Popular" or "Custom"
- Control plan visibility and ordering
- Update pricing without code changes
## Features
### Pricing Plan Management
- **Plan Name**: Display name for the pricing plan
- **Description**: Optional description shown below the plan name
- **Pricing**: Monthly and annual prices (annual prices are typically 20% lower)
- **Features**: Dynamic list of features with checkmarks
- **Button Configuration**: Customizable button text and URL
- **Plan Types**: Regular plans with prices or custom plans
- **Popular Plans**: Mark one plan as "Most Popular" with special styling
- **Active/Inactive**: Toggle plan visibility
- **Ordering**: Control the display order of plans
### Resource Quotas
- **Room Quota**: Maximum number of rooms allowed (0 = unlimited)
- **Conversation Quota**: Maximum number of conversations allowed (0 = unlimited)
- **Storage Quota**: Maximum storage in gigabytes (0 = unlimited)
- **Manager Quota**: Maximum number of manager users allowed (0 = unlimited)
- **Admin Quota**: Maximum number of admin users allowed (0 = unlimited)
### Admin Interface
- **Pricing Tab**: New tab in admin settings (MASTER instances only)
- **Add/Edit Modals**: User-friendly forms for plan management
- **Real-time Updates**: Changes are reflected immediately
- **Feature Management**: Add/remove features dynamically
- **Quota Configuration**: Set resource limits for each plan
- **Status Toggles**: Quick switches for plan properties
## Setup Instructions
### 1. Database Migration
Run the migrations to create the pricing_plans table and add quota fields:
```bash
# Apply the migrations
alembic upgrade head
```
### 2. Initialize Default Plans (Optional)
Run the initialization script to create default pricing plans:
```bash
# Set MASTER environment variable
export MASTER=true
# Run the initialization script
python init_pricing_plans.py
```
This will create four default plans with quotas:
- **Starter**: 5 rooms, 10 conversations, 10GB storage, 10 managers, 1 admin
- **Professional**: 25 rooms, 50 conversations, 100GB storage, 50 managers, 3 admins
- **Enterprise**: 100 rooms, 200 conversations, 500GB storage, 200 managers, 10 admins
- **Custom**: Unlimited everything
### 3. Access Admin Interface
1. Log in as an admin user on a MASTER instance
2. Go to Settings
3. Click on the "Pricing" tab
4. Configure your pricing plans
## Usage
### Creating a New Plan
1. Click "Add New Plan" in the pricing tab
2. Fill in the plan details:
- **Name**: Plan display name
- **Description**: Optional description
- **Monthly Price**: Price per month
- **Annual Price**: Price per month when billed annually
- **Quotas**: Set resource limits (0 = unlimited)
- **Features**: Add features using the "Add Feature" button
- **Button Text**: Text for the call-to-action button
- **Button URL**: URL the button should link to
- **Options**: Check "Most Popular", "Custom Plan", or "Active" as needed
3. Click "Create Plan"
### Editing a Plan
1. Click the "Edit" button on any plan card
2. Modify the plan details in the modal
3. Click "Update Plan"
### Managing Plan Status
- **Active/Inactive**: Use the toggle switch in the plan header
- **Most Popular**: Check the "Most Popular" checkbox (only one plan can be popular)
- **Custom Plan**: Check "Custom Plan" for plans without fixed pricing
### Deleting a Plan
1. Click the "Delete" button on a plan card
2. Confirm the deletion in the modal
## Technical Details
### Database Schema
The `pricing_plans` table includes:
- `id`: Primary key
- `name`: Plan name (required)
- `description`: Optional description
- `monthly_price`: Monthly price (float)
- `annual_price`: Annual price (float)
- `features`: JSON array of feature strings
- `button_text`: Button display text
- `button_url`: Button link URL
- `is_popular`: Boolean for "Most Popular" styling
- `is_custom`: Boolean for custom plans
- `is_active`: Boolean for plan visibility
- `order_index`: Integer for display ordering
- `room_quota`: Maximum rooms (0 = unlimited)
- `conversation_quota`: Maximum conversations (0 = unlimited)
- `storage_quota_gb`: Maximum storage in GB (0 = unlimited)
- `manager_quota`: Maximum managers (0 = unlimited)
- `admin_quota`: Maximum admins (0 = unlimited)
- `created_by`: Foreign key to user who created the plan
- `created_at`/`updated_at`: Timestamps
### API Endpoints
- `POST /api/admin/pricing-plans` - Create new plan
- `GET /api/admin/pricing-plans/<id>` - Get plan details
- `PUT /api/admin/pricing-plans/<id>` - Update plan
- `DELETE /api/admin/pricing-plans/<id>` - Delete plan
- `PATCH /api/admin/pricing-plans/<id>/status` - Update plan status
### Template Integration
The pricing page automatically uses configured plans:
- Falls back to hardcoded plans if no plans are configured
- Supports dynamic feature lists
- Handles custom plans without pricing
- Shows/hides billing toggle based on plan types
- Displays quota information in plan cards
### Quota Enforcement
The PricingPlan model includes utility methods for quota checking:
- `check_quota(quota_type, current_count)`: Returns True if quota allows the operation
- `get_quota_remaining(quota_type, current_count)`: Returns remaining quota
- `format_quota_display(quota_type)`: Formats quota for display
- `get_storage_quota_bytes()`: Converts GB to bytes for storage calculations
Example usage in your application:
```python
# Check if user can create a new room
plan = PricingPlan.query.get(user_plan_id)
current_rooms = Room.query.filter_by(created_by=user.id).count()
if plan.check_quota('room_quota', current_rooms):
# Allow room creation
pass
else:
# Show upgrade message
pass
```
## Security
- Only admin users can access pricing configuration
- Only MASTER instances can configure pricing
- All API endpoints require authentication and admin privileges
- CSRF protection is enabled for all forms
## Customization
### Styling
The pricing plans use the existing CSS variables:
- `--primary-color`: Main brand color
- `--secondary-color`: Secondary brand color
- `--shadow-color`: Card shadows
### Button URLs
Configure button URLs to point to:
- Contact forms
- Payment processors
- Sales pages
- Custom landing pages
### Features
Features can include:
- Storage limits
- User limits
- Feature availability
- Support levels
- Integration options
### Quota Integration
To integrate quotas into your application:
1. **User Plan Assignment**: Associate users with pricing plans
2. **Quota Checking**: Use the `check_quota()` method before operations
3. **Upgrade Prompts**: Show upgrade messages when quotas are exceeded
4. **Usage Tracking**: Track current usage for quota calculations
## Troubleshooting
### Common Issues
1. **Pricing tab not visible**
- Ensure you're on a MASTER instance (`MASTER=true`)
- Ensure you're logged in as an admin user
2. **Plans not showing on pricing page**
- Check that plans are marked as "Active"
- Verify the database migration was applied
- Check for JavaScript errors in browser console
3. **Features not saving**
- Ensure at least one feature is provided
- Check that feature text is not empty
4. **Quota fields not working**
- Verify the quota migration was applied
- Check that quota values are integers
- Ensure quota fields are included in form submissions
5. **API errors**
- Verify CSRF token is included in requests
- Check that all required fields are provided
- Ensure proper JSON formatting for features
### Debugging
- Check browser console for JavaScript errors
- Review server logs for API errors
- Verify database connectivity
- Test with default plans first
## Future Enhancements
Potential future improvements:
- Plan categories/tiers
- Regional pricing
- Currency support
- Promotional pricing
- Plan comparison features
- Analytics and usage tracking
- Automatic quota enforcement middleware
- Usage dashboard for quota monitoring

Binary file not shown.

Binary file not shown.

View File

@@ -1,59 +0,0 @@
import os
import shutil
from app import create_app
from models import db, RoomFile, Room, RoomMemberPermission
from sqlalchemy import text
app = create_app()
def clear_all_data():
with app.app_context():
# Delete records in the correct order to handle foreign key constraints
# 1. Delete all RoomFile records from the database
RoomFile.query.delete()
print("All RoomFile records deleted.")
# 2. Delete all RoomMemberPermission records
RoomMemberPermission.query.delete()
print("All RoomMemberPermission records deleted.")
# 3. Delete all room_members associations
db.session.execute(text('DELETE FROM room_members'))
print("All room_members associations deleted.")
# 4. Delete all Room records
Room.query.delete()
print("All Room records deleted.")
# Commit the database changes
db.session.commit()
print("Database cleanup completed.")
def clear_filesystem():
# 1. Clear the data/rooms directory
data_root = os.path.join(os.path.dirname(__file__), 'data', 'rooms')
if os.path.exists(data_root):
for item in os.listdir(data_root):
item_path = os.path.join(data_root, item)
if os.path.isfile(item_path):
os.remove(item_path)
elif os.path.isdir(item_path):
shutil.rmtree(item_path)
print("Cleared data/rooms directory")
# 2. Clear the uploads directory except for profile_pics
uploads_dir = os.path.join(os.path.dirname(__file__), 'uploads')
if os.path.exists(uploads_dir):
for item in os.listdir(uploads_dir):
if item != 'profile_pics':
item_path = os.path.join(uploads_dir, item)
if os.path.isfile(item_path):
os.remove(item_path)
elif os.path.isdir(item_path):
shutil.rmtree(item_path)
print("Cleared uploads directory")
if __name__ == '__main__':
clear_all_data()
clear_filesystem()
print("Cleanup completed successfully!")

View File

@@ -1,27 +0,0 @@
from app import create_app, db
from app.models import RoomFile, Room
import os
app = create_app()
with app.app_context():
# Get the Test room
room = Room.query.filter_by(name='Test').first()
if not room:
print("Test room not found")
exit(1)
# Delete from database
files = ['Screenshot_2025-03-19_100338.png', 'Screenshot_2025-03-19_100419.png']
deleted = RoomFile.query.filter_by(room_id=room.id, name__in=files).delete()
db.session.commit()
print(f"Deleted {deleted} records from database")
# Delete from filesystem
room_path = os.path.join('data', 'rooms', str(room.id))
for file in files:
file_path = os.path.join(room_path, file)
if os.path.exists(file_path):
os.remove(file_path)
print(f"Deleted file: {file_path}")
else:
print(f"File not found: {file_path}")

View File

@@ -1,11 +0,0 @@
from app import app, db
from models import Notif
def create_notifs_table():
with app.app_context():
# Create the table
Notif.__table__.create(db.engine)
print("Notifications table created successfully!")
if __name__ == '__main__':
create_notifs_table()

View File

@@ -25,6 +25,13 @@ services:
- GIT_COMMIT=${GIT_COMMIT:-unknown} - GIT_COMMIT=${GIT_COMMIT:-unknown}
- GIT_BRANCH=${GIT_BRANCH:-unknown} - GIT_BRANCH=${GIT_BRANCH:-unknown}
- DEPLOYED_AT=${DEPLOYED_AT:-unknown} - DEPLOYED_AT=${DEPLOYED_AT:-unknown}
- PRICING_TIER_ID=${PRICING_TIER_ID:-0}
- PRICING_TIER_NAME=${PRICING_TIER_NAME:-Unknown}
- ROOM_QUOTA=${ROOM_QUOTA:-0}
- CONVERSATION_QUOTA=${CONVERSATION_QUOTA:-0}
- STORAGE_QUOTA_GB=${STORAGE_QUOTA_GB:-0}
- MANAGER_QUOTA=${MANAGER_QUOTA:-0}
- ADMIN_QUOTA=${ADMIN_QUOTA:-0}
volumes: volumes:
- docupulse_uploads:/app/uploads - docupulse_uploads:/app/uploads
depends_on: depends_on:

View File

@@ -0,0 +1,24 @@
"""add_foreign_key_to_customer_subscription_plan_id
Revision ID: 3198363f8c4f
Revises: add_customer_table
Create Date: 2025-06-26 14:35:09.377247
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3198363f8c4f'
down_revision = 'add_customer_table'
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -0,0 +1,50 @@
"""replace_stripe_links_with_product_ids
Revision ID: 421f02ac5f59
Revises: add_stripe_payment_links
Create Date: 2025-06-26 13:49:45.124311
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = '421f02ac5f59'
down_revision = 'add_stripe_payment_links'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
# Check if new columns already exist
result = conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'pricing_plans'
AND column_name IN ('stripe_product_id', 'stripe_monthly_price_id', 'stripe_annual_price_id')
"""))
existing_columns = [row[0] for row in result.fetchall()]
# Add new Stripe product/price ID columns if they don't exist
if 'stripe_product_id' not in existing_columns:
op.add_column('pricing_plans', sa.Column('stripe_product_id', sa.String(length=100), nullable=True))
if 'stripe_monthly_price_id' not in existing_columns:
op.add_column('pricing_plans', sa.Column('stripe_monthly_price_id', sa.String(length=100), nullable=True))
if 'stripe_annual_price_id' not in existing_columns:
op.add_column('pricing_plans', sa.Column('stripe_annual_price_id', sa.String(length=100), nullable=True))
# Note: We'll keep the old payment link columns for now to allow for a gradual migration
# They can be removed in a future migration after the new system is fully implemented
def downgrade():
# Remove the new Stripe product/price ID columns
op.drop_column('pricing_plans', 'stripe_annual_price_id')
op.drop_column('pricing_plans', 'stripe_monthly_price_id')
op.drop_column('pricing_plans', 'stripe_product_id')

View File

@@ -0,0 +1,57 @@
"""add customer table for Stripe customers
Revision ID: add_customer_table
Revises: 421f02ac5f59
Create Date: 2025-06-27 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'add_customer_table'
down_revision = '421f02ac5f59'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
inspector = inspect(conn)
tables = inspector.get_table_names()
if 'customer' not in tables:
op.create_table(
'customer',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.Column('email', sa.String(150), nullable=False),
sa.Column('name', sa.String(150), nullable=True),
sa.Column('phone', sa.String(50), nullable=True),
sa.Column('billing_address_line1', sa.String(255), nullable=True),
sa.Column('billing_address_line2', sa.String(255), nullable=True),
sa.Column('billing_city', sa.String(100), nullable=True),
sa.Column('billing_state', sa.String(100), nullable=True),
sa.Column('billing_postal_code', sa.String(20), nullable=True),
sa.Column('billing_country', sa.String(100), nullable=True),
sa.Column('shipping_address_line1', sa.String(255), nullable=True),
sa.Column('shipping_address_line2', sa.String(255), nullable=True),
sa.Column('shipping_city', sa.String(100), nullable=True),
sa.Column('shipping_state', sa.String(100), nullable=True),
sa.Column('shipping_postal_code', sa.String(20), nullable=True),
sa.Column('shipping_country', sa.String(100), nullable=True),
sa.Column('tax_id_type', sa.String(50), nullable=True),
sa.Column('tax_id_value', sa.String(100), nullable=True),
sa.Column('stripe_customer_id', sa.String(255), nullable=True),
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
sa.Column('subscription_status', sa.String(50), nullable=True),
sa.Column('subscription_plan_id', sa.Integer, nullable=True),
sa.Column('subscription_billing_cycle', sa.String(20), nullable=True),
sa.Column('subscription_current_period_start', sa.DateTime, nullable=True),
sa.Column('subscription_current_period_end', sa.DateTime, nullable=True),
)
op.create_index('idx_customer_email', 'customer', ['email'])
def downgrade():
op.drop_index('idx_customer_email', table_name='customer')
op.drop_table('customer')

View File

@@ -0,0 +1,43 @@
"""add stripe payment links to pricing plans
Revision ID: add_stripe_payment_links
Revises: 9206bf87bb8e
Create Date: 2024-12-19 13:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'add_stripe_payment_links'
down_revision = '9206bf87bb8e'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
# Check if columns already exist
result = conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'pricing_plans'
AND column_name IN ('monthly_stripe_link', 'annual_stripe_link')
"""))
existing_columns = [row[0] for row in result.fetchall()]
# Add Stripe payment link columns if they don't exist
if 'monthly_stripe_link' not in existing_columns:
op.add_column('pricing_plans', sa.Column('monthly_stripe_link', sa.String(length=500), nullable=True))
if 'annual_stripe_link' not in existing_columns:
op.add_column('pricing_plans', sa.Column('annual_stripe_link', sa.String(length=500), nullable=True))
def downgrade():
# Remove Stripe payment link columns
op.drop_column('pricing_plans', 'annual_stripe_link')
op.drop_column('pricing_plans', 'monthly_stripe_link')

View File

@@ -0,0 +1,30 @@
"""add_foreign_key_to_customer_subscription_plan_id
Revision ID: cc03b4419053
Revises: 3198363f8c4f
Create Date: 2025-06-26 14:35:15.661164
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cc03b4419053'
down_revision = '3198363f8c4f'
branch_labels = None
depends_on = None
def upgrade():
# Add foreign key constraint if it doesn't exist
op.create_foreign_key(
'fk_customer_subscription_plan_id',
'customer', 'pricing_plan',
['subscription_plan_id'], ['id'],
ondelete='SET NULL'
)
def downgrade():
op.drop_constraint('fk_customer_subscription_plan_id', 'customer', type_='foreignkey')

View File

@@ -603,6 +603,13 @@ class PricingPlan(db.Model):
is_custom = db.Column(db.Boolean, default=False) is_custom = db.Column(db.Boolean, default=False)
button_text = db.Column(db.String(50), default='Get Started') button_text = db.Column(db.String(50), default='Get Started')
button_url = db.Column(db.String(200), default='#') button_url = db.Column(db.String(200), default='#')
# Stripe integration fields
stripe_product_id = db.Column(db.String(100), nullable=True)
stripe_monthly_price_id = db.Column(db.String(100), nullable=True)
stripe_annual_price_id = db.Column(db.String(100), nullable=True)
# Deprecated: Stripe payment links (to be removed in a future migration)
monthly_stripe_link = db.Column(db.String(500), nullable=True) # DEPRECATED
annual_stripe_link = db.Column(db.String(500), nullable=True) # DEPRECATED
order_index = db.Column(db.Integer, default=0) order_index = db.Column(db.Integer, default=0)
is_active = db.Column(db.Boolean, default=True) is_active = db.Column(db.Boolean, default=True)
# Quota fields # Quota fields
@@ -678,3 +685,38 @@ class PricingPlan(db.Model):
elif quota_type == 'admin_quota': elif quota_type == 'admin_quota':
return float('inf') if self.admin_quota == 0 else max(0, self.admin_quota - current_count) return float('inf') if self.admin_quota == 0 else max(0, self.admin_quota - current_count)
return 0 return 0
class Customer(db.Model):
__tablename__ = 'customer'
id = db.Column(db.Integer, primary_key=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
email = db.Column(db.String(150), nullable=False, index=True)
name = db.Column(db.String(150))
phone = db.Column(db.String(50))
billing_address_line1 = db.Column(db.String(255))
billing_address_line2 = db.Column(db.String(255))
billing_city = db.Column(db.String(100))
billing_state = db.Column(db.String(100))
billing_postal_code = db.Column(db.String(20))
billing_country = db.Column(db.String(100))
shipping_address_line1 = db.Column(db.String(255))
shipping_address_line2 = db.Column(db.String(255))
shipping_city = db.Column(db.String(100))
shipping_state = db.Column(db.String(100))
shipping_postal_code = db.Column(db.String(20))
shipping_country = db.Column(db.String(100))
tax_id_type = db.Column(db.String(50))
tax_id_value = db.Column(db.String(100))
stripe_customer_id = db.Column(db.String(255))
stripe_subscription_id = db.Column(db.String(255))
subscription_status = db.Column(db.String(50))
subscription_plan_id = db.Column(db.Integer, db.ForeignKey('pricing_plans.id'))
subscription_billing_cycle = db.Column(db.String(20))
subscription_current_period_start = db.Column(db.DateTime)
subscription_current_period_end = db.Column(db.DateTime)
# Relationship to pricing plan
plan = db.relationship('PricingPlan', backref='customers')
def __repr__(self):
return f'<Customer {self.email}>'

View File

@@ -14,3 +14,4 @@ requests>=2.31.0
gunicorn==21.2.0 gunicorn==21.2.0
prometheus-client>=0.16.0 prometheus-client>=0.16.0
PyJWT>=2.8.0 PyJWT>=2.8.0
stripe>=7.0.0

View File

@@ -1,9 +1,12 @@
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request, render_template, flash, redirect, url_for, current_app
from flask_login import login_required, current_user from flask_login import login_required, current_user
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle, Customer
from extensions import csrf
from utils.event_logger import log_event
import os import os
from datetime import datetime from datetime import datetime
import json import json
from routes.auth import require_password_change
admin = Blueprint('admin', __name__) admin = Blueprint('admin', __name__)
@@ -258,6 +261,7 @@ def get_usage_stats():
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['PUT']) @admin.route('/api/admin/help-articles/<int:article_id>', methods=['PUT'])
@login_required @login_required
@csrf.exempt
def update_help_article(article_id): def update_help_article(article_id):
"""Update a help article""" """Update a help article"""
if not current_user.is_admin: if not current_user.is_admin:
@@ -310,6 +314,7 @@ def update_help_article(article_id):
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['DELETE']) @admin.route('/api/admin/help-articles/<int:article_id>', methods=['DELETE'])
@login_required @login_required
@csrf.exempt
def delete_help_article(article_id): def delete_help_article(article_id):
"""Delete a help article""" """Delete a help article"""
if not current_user.is_admin: if not current_user.is_admin:
@@ -342,6 +347,7 @@ def delete_help_article(article_id):
# Help Articles API endpoints # Help Articles API endpoints
@admin.route('/api/admin/help-articles', methods=['GET']) @admin.route('/api/admin/help-articles', methods=['GET'])
@login_required @login_required
@csrf.exempt
def get_help_articles(): def get_help_articles():
"""Get all help articles""" """Get all help articles"""
if not current_user.is_admin: if not current_user.is_admin:
@@ -367,6 +373,7 @@ def get_help_articles():
@admin.route('/api/admin/help-articles', methods=['POST']) @admin.route('/api/admin/help-articles', methods=['POST'])
@login_required @login_required
@csrf.exempt
def create_help_article(): def create_help_article():
"""Create a new help article""" """Create a new help article"""
if not current_user.is_admin: if not current_user.is_admin:
@@ -420,6 +427,7 @@ def create_help_article():
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['GET']) @admin.route('/api/admin/help-articles/<int:article_id>', methods=['GET'])
@login_required @login_required
@csrf.exempt
def get_help_article(article_id): def get_help_article(article_id):
"""Get a specific help article""" """Get a specific help article"""
if not current_user.is_admin: if not current_user.is_admin:
@@ -454,6 +462,7 @@ def create_pricing_plan():
try: try:
from models import PricingPlan from models import PricingPlan
from utils.stripe_utils import create_stripe_product
# Get form data # Get form data
name = request.form.get('name') name = request.form.get('name')
@@ -462,11 +471,15 @@ def create_pricing_plan():
annual_price = float(request.form.get('annual_price')) annual_price = float(request.form.get('annual_price'))
features = json.loads(request.form.get('features', '[]')) features = json.loads(request.form.get('features', '[]'))
button_text = request.form.get('button_text', 'Get Started') button_text = request.form.get('button_text', 'Get Started')
button_url = request.form.get('button_url', '#')
is_popular = request.form.get('is_popular') == 'true' is_popular = request.form.get('is_popular') == 'true'
is_custom = request.form.get('is_custom') == 'true' is_custom = request.form.get('is_custom') == 'true'
is_active = request.form.get('is_active') == 'true' is_active = request.form.get('is_active') == 'true'
# Get Stripe ID fields
stripe_product_id = request.form.get('stripe_product_id', '')
stripe_monthly_price_id = request.form.get('stripe_monthly_price_id', '')
stripe_annual_price_id = request.form.get('stripe_annual_price_id', '')
# Get quota fields # Get quota fields
room_quota = int(request.form.get('room_quota', 0)) room_quota = int(request.form.get('room_quota', 0))
conversation_quota = int(request.form.get('conversation_quota', 0)) conversation_quota = int(request.form.get('conversation_quota', 0))
@@ -489,7 +502,9 @@ def create_pricing_plan():
annual_price=annual_price, annual_price=annual_price,
features=features, features=features,
button_text=button_text, button_text=button_text,
button_url=button_url, stripe_product_id=stripe_product_id,
stripe_monthly_price_id=stripe_monthly_price_id,
stripe_annual_price_id=stripe_annual_price_id,
is_popular=is_popular, is_popular=is_popular,
is_custom=is_custom, is_custom=is_custom,
is_active=is_active, is_active=is_active,
@@ -505,6 +520,18 @@ def create_pricing_plan():
db.session.add(plan) db.session.add(plan)
db.session.commit() db.session.commit()
# If no Stripe IDs provided and plan is not custom, try to create Stripe product
if not is_custom and not stripe_product_id:
try:
stripe_data = create_stripe_product(plan)
plan.stripe_product_id = stripe_data['product_id']
plan.stripe_monthly_price_id = stripe_data['monthly_price_id']
plan.stripe_annual_price_id = stripe_data['annual_price_id']
db.session.commit()
except Exception as stripe_error:
# Log the error but don't fail the plan creation
current_app.logger.warning(f"Failed to create Stripe product for plan {plan.name}: {str(stripe_error)}")
return jsonify({'success': True, 'message': 'Pricing plan created successfully'}) return jsonify({'success': True, 'message': 'Pricing plan created successfully'})
except Exception as e: except Exception as e:
@@ -535,6 +562,9 @@ def get_pricing_plan(plan_id):
'features': plan.features, 'features': plan.features,
'button_text': plan.button_text, 'button_text': plan.button_text,
'button_url': plan.button_url, 'button_url': plan.button_url,
'stripe_product_id': plan.stripe_product_id,
'stripe_monthly_price_id': plan.stripe_monthly_price_id,
'stripe_annual_price_id': plan.stripe_annual_price_id,
'is_popular': plan.is_popular, 'is_popular': plan.is_popular,
'is_custom': plan.is_custom, 'is_custom': plan.is_custom,
'is_active': plan.is_active, 'is_active': plan.is_active,
@@ -563,6 +593,7 @@ def update_pricing_plan(plan_id):
try: try:
from models import PricingPlan from models import PricingPlan
from utils.stripe_utils import update_stripe_product
plan = PricingPlan.query.get(plan_id) plan = PricingPlan.query.get(plan_id)
if not plan: if not plan:
@@ -575,11 +606,15 @@ def update_pricing_plan(plan_id):
annual_price = float(request.form.get('annual_price')) annual_price = float(request.form.get('annual_price'))
features = json.loads(request.form.get('features', '[]')) features = json.loads(request.form.get('features', '[]'))
button_text = request.form.get('button_text', 'Get Started') button_text = request.form.get('button_text', 'Get Started')
button_url = request.form.get('button_url', '#')
is_popular = request.form.get('is_popular') == 'true' is_popular = request.form.get('is_popular') == 'true'
is_custom = request.form.get('is_custom') == 'true' is_custom = request.form.get('is_custom') == 'true'
is_active = request.form.get('is_active') == 'true' is_active = request.form.get('is_active') == 'true'
# Get Stripe ID fields
stripe_product_id = request.form.get('stripe_product_id', '')
stripe_monthly_price_id = request.form.get('stripe_monthly_price_id', '')
stripe_annual_price_id = request.form.get('stripe_annual_price_id', '')
# Get quota fields # Get quota fields
room_quota = int(request.form.get('room_quota', 0)) room_quota = int(request.form.get('room_quota', 0))
conversation_quota = int(request.form.get('conversation_quota', 0)) conversation_quota = int(request.form.get('conversation_quota', 0))
@@ -598,7 +633,9 @@ def update_pricing_plan(plan_id):
plan.annual_price = annual_price plan.annual_price = annual_price
plan.features = features plan.features = features
plan.button_text = button_text plan.button_text = button_text
plan.button_url = button_url plan.stripe_product_id = stripe_product_id
plan.stripe_monthly_price_id = stripe_monthly_price_id
plan.stripe_annual_price_id = stripe_annual_price_id
plan.is_popular = is_popular plan.is_popular = is_popular
plan.is_custom = is_custom plan.is_custom = is_custom
plan.is_active = is_active plan.is_active = is_active
@@ -610,6 +647,18 @@ def update_pricing_plan(plan_id):
db.session.commit() db.session.commit()
# If plan has existing Stripe product and is not custom, try to update it
if not is_custom and plan.stripe_product_id:
try:
stripe_data = update_stripe_product(plan)
plan.stripe_product_id = stripe_data['product_id']
plan.stripe_monthly_price_id = stripe_data['monthly_price_id']
plan.stripe_annual_price_id = stripe_data['annual_price_id']
db.session.commit()
except Exception as stripe_error:
# Log the error but don't fail the plan update
current_app.logger.warning(f"Failed to update Stripe product for plan {plan.name}: {str(stripe_error)}")
return jsonify({'success': True, 'message': 'Pricing plan updated successfully'}) return jsonify({'success': True, 'message': 'Pricing plan updated successfully'})
except Exception as e: except Exception as e:
@@ -726,3 +775,60 @@ def get_pricing_plans():
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@admin.route('/customers')
@login_required
@require_password_change
def customers():
"""View all customers"""
if not current_user.is_admin:
flash('Access denied. Admin privileges required.', 'error')
return redirect(url_for('main.dashboard'))
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
flash('Access denied. Master admin privileges required.', 'error')
return redirect(url_for('main.dashboard'))
# Get all customers with pagination
page = request.args.get('page', 1, type=int)
per_page = 20
customers = Customer.query.order_by(Customer.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template('admin/customers.html', customers=customers)
@admin.route('/customers/<int:customer_id>')
@login_required
@require_password_change
def get_customer_details(customer_id):
"""Get customer details for modal"""
if not current_user.is_admin:
return jsonify({'error': 'Access denied'}), 403
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
return jsonify({'error': 'Access denied'}), 403
try:
customer = Customer.query.get_or_404(customer_id)
# Get the associated plan
plan = None
if customer.subscription_plan_id:
from models import PricingPlan
plan = PricingPlan.query.get(customer.subscription_plan_id)
html = render_template('admin/customer_details_modal.html', customer=customer, plan=plan)
return jsonify({
'success': True,
'html': html
})
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -929,8 +929,8 @@ def deploy_stack():
def check_stack_status(): def check_stack_status():
try: try:
data = request.get_json() data = request.get_json()
if not data or 'stack_name' not in data: if not data or ('stack_name' not in data and 'stack_id' not in data):
return jsonify({'error': 'Missing stack_name field'}), 400 return jsonify({'error': 'Missing stack_name or stack_id field'}), 400
# Get Portainer settings # Get Portainer settings
portainer_settings = KeyValueSettings.get_value('portainer_settings') portainer_settings = KeyValueSettings.get_value('portainer_settings')
@@ -956,7 +956,26 @@ def check_stack_status():
endpoint_id = endpoints[0]['Id'] endpoint_id = endpoints[0]['Id']
# Get stack information # Get stack information - support both stack_name and stack_id
if 'stack_id' in data:
# Get stack by ID
stack_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
stack_response = requests.get(
stack_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'endpointId': endpoint_id},
timeout=30
)
if not stack_response.ok:
return jsonify({'error': f'Stack with ID {data["stack_id"]} not found'}), 404
target_stack = stack_response.json()
else:
# Get stack by name (existing logic)
stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks" stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks"
stacks_response = requests.get( stacks_response = requests.get(
stacks_url, stacks_url,
@@ -984,7 +1003,7 @@ def check_stack_status():
# Get stack services to check their status # Get stack services to check their status
services_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/services" services_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/services"
current_app.logger.info(f"Checking services for stack {data['stack_name']} at endpoint {endpoint_id}") current_app.logger.info(f"Checking services for stack {target_stack['Name']} at endpoint {endpoint_id}")
try: try:
services_response = requests.get( services_response = requests.get(
@@ -993,7 +1012,7 @@ def check_stack_status():
'X-API-Key': portainer_settings['api_key'], 'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json' 'Accept': 'application/json'
}, },
params={'filters': json.dumps({'label': f'com.docker.stack.namespace={data["stack_name"]}'})}, params={'filters': json.dumps({'label': f'com.docker.stack.namespace={target_stack["Name"]}'})},
timeout=30 timeout=30
) )
@@ -1001,46 +1020,40 @@ def check_stack_status():
if services_response.ok: if services_response.ok:
services = services_response.json() services = services_response.json()
current_app.logger.info(f"Found {len(services)} services for stack {data['stack_name']}")
# Check if all services are running
all_running = True
service_statuses = [] service_statuses = []
for service in services: for service in services:
replicas_running = service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0) service_statuses.append({
replicas_actual = service.get('ServiceStatus', {}).get('RunningTasks', 0)
service_status = {
'name': service.get('Spec', {}).get('Name', 'Unknown'), 'name': service.get('Spec', {}).get('Name', 'Unknown'),
'replicas_expected': replicas_running, 'replicas': service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0),
'replicas_running': replicas_actual, 'running_replicas': service.get('ServiceStatus', {}).get('RunningTasks', 0),
'status': 'running' if replicas_actual >= replicas_running else 'not_running' 'desired_replicas': service.get('ServiceStatus', {}).get('DesiredTasks', 0)
} })
service_statuses.append(service_status)
if replicas_actual < replicas_running:
all_running = False
# Determine overall stack status # Determine overall stack status
if all_running and len(services) > 0: if not service_statuses:
status = 'starting' # No services found yet
else:
all_running = all(s['running_replicas'] >= s['desired_replicas'] for s in service_statuses if s['desired_replicas'] > 0)
any_running = any(s['running_replicas'] > 0 for s in service_statuses)
if all_running:
status = 'active' status = 'active'
elif len(services) > 0: elif any_running:
status = 'partial' status = 'partial'
else: else:
status = 'inactive' status = 'inactive'
else: else:
# Services API failed, but stack exists - assume it's still starting up # Services API failed, but stack exists - assume it's still starting up
current_app.logger.warning(f"Failed to get services for stack {data['stack_name']}: {services_response.status_code} - {services_response.text}") current_app.logger.warning(f"Failed to get services for stack {target_stack['Name']}: {services_response.status_code} - {services_response.text}")
# Provide more specific error context # Provide more specific error context
if services_response.status_code == 404: if services_response.status_code == 404:
current_app.logger.info(f"Services endpoint not found for stack {data['stack_name']} - stack may still be initializing") current_app.logger.info(f"Services endpoint not found for stack {target_stack['Name']} - stack may still be initializing")
elif services_response.status_code == 403: elif services_response.status_code == 403:
current_app.logger.warning(f"Access denied to services for stack {data['stack_name']} - check Portainer permissions") current_app.logger.warning(f"Access denied to services for stack {target_stack['Name']} - check Portainer permissions")
elif services_response.status_code >= 500: elif services_response.status_code >= 500:
current_app.logger.warning(f"Portainer server error when getting services for stack {data['stack_name']}") current_app.logger.warning(f"Portainer server error when getting services for stack {target_stack['Name']}")
services = [] services = []
service_statuses = [] service_statuses = []
@@ -1048,7 +1061,7 @@ def check_stack_status():
except Exception as e: except Exception as e:
# Exception occurred while getting services, but stack exists # Exception occurred while getting services, but stack exists
current_app.logger.warning(f"Exception getting services for stack {data['stack_name']}: {str(e)}") current_app.logger.warning(f"Exception getting services for stack {target_stack['Name']}: {str(e)}")
services = [] services = []
service_statuses = [] service_statuses = []
status = 'starting' # Stack exists but services not available yet status = 'starting' # Stack exists but services not available yet
@@ -1056,14 +1069,10 @@ def check_stack_status():
return jsonify({ return jsonify({
'success': True, 'success': True,
'data': { 'data': {
'stack_name': data['stack_name'], 'name': target_stack['Name'],
'stack_id': target_stack['Id'], 'stack_id': target_stack['Id'],
'status': status, 'status': status,
'services': service_statuses, 'services': service_statuses
'total_services': len(services),
'running_services': len([s for s in service_statuses if s['status'] == 'running']),
'stack_created_at': target_stack.get('CreatedAt', 'unknown'),
'stack_updated_at': target_stack.get('UpdatedAt', 'unknown')
} }
}) })
@@ -1855,3 +1864,215 @@ def copy_smtp_settings():
except Exception as e: except Exception as e:
current_app.logger.error(f"Error copying SMTP settings: {str(e)}") current_app.logger.error(f"Error copying SMTP settings: {str(e)}")
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@launch_api.route('/update-stack', methods=['POST'])
@csrf.exempt
def update_stack():
try:
data = request.get_json()
if not data or 'stack_id' not in data:
return jsonify({'error': 'Missing required fields'}), 400
# Get Portainer settings
portainer_settings = KeyValueSettings.get_value('portainer_settings')
if not portainer_settings:
return jsonify({'error': 'Portainer settings not configured'}), 400
# Define timeout early to ensure it's available throughout the function
stack_timeout = current_app.config.get('STACK_DEPLOYMENT_TIMEOUT', 300) # Default to 5 minutes
# Verify Portainer authentication
auth_response = requests.get(
f"{portainer_settings['url'].rstrip('/')}/api/status",
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
timeout=30 # 30 seconds timeout for status check
)
if not auth_response.ok:
current_app.logger.error(f"Portainer authentication failed: {auth_response.text}")
return jsonify({'error': 'Failed to authenticate with Portainer'}), 401
# Get Portainer endpoint ID (assuming it's the first endpoint)
endpoint_response = requests.get(
f"{portainer_settings['url'].rstrip('/')}/api/endpoints",
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
timeout=30 # 30 seconds timeout for endpoint check
)
if not endpoint_response.ok:
error_text = endpoint_response.text
try:
error_json = endpoint_response.json()
error_text = error_json.get('message', error_text)
except:
pass
return jsonify({'error': f'Failed to get Portainer endpoints: {error_text}'}), 500
endpoints = endpoint_response.json()
if not endpoints:
return jsonify({'error': 'No Portainer endpoints found'}), 400
endpoint_id = endpoints[0]['Id']
# Log the request data
current_app.logger.info(f"Updating stack with ID: {data['stack_id']}")
current_app.logger.info(f"Using endpoint ID: {endpoint_id}")
current_app.logger.info(f"Using timeout: {stack_timeout} seconds")
# First, verify the stack exists and get its current configuration
stack_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
stack_response = requests.get(
stack_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'endpointId': endpoint_id},
timeout=30
)
if not stack_response.ok:
return jsonify({'error': f'Stack with ID {data["stack_id"]} not found'}), 404
stack_info = stack_response.json()
current_app.logger.info(f"Found existing stack: {stack_info['Name']} (ID: {stack_info['Id']})")
# Get the current stack file content from Portainer
stack_file_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}/file"
stack_file_response = requests.get(
stack_file_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'endpointId': endpoint_id},
timeout=30
)
if not stack_file_response.ok:
current_app.logger.error(f"Failed to get stack file: {stack_file_response.text}")
return jsonify({'error': f'Failed to get existing stack file: {stack_file_response.text}'}), 500
stack_file_data = stack_file_response.json()
current_stack_file_content = stack_file_data.get('StackFileContent')
if not current_stack_file_content:
current_app.logger.error("No StackFileContent found in existing stack")
return jsonify({'error': 'No existing stack file content found'}), 500
current_app.logger.info("Retrieved existing stack file content")
# Get existing environment variables from the stack
existing_env_vars = stack_file_data.get('Env', [])
current_app.logger.info(f"Retrieved {len(existing_env_vars)} existing environment variables")
# Create a dictionary of existing environment variables for easy lookup
existing_env_dict = {env['name']: env['value'] for env in existing_env_vars}
current_app.logger.info(f"Existing environment variables: {list(existing_env_dict.keys())}")
# Get new environment variables from the request
new_env_vars = data.get('Env', [])
current_app.logger.info(f"New environment variables to update: {[env['name'] for env in new_env_vars]}")
# Merge existing and new environment variables
# Start with existing variables
merged_env_vars = existing_env_vars.copy()
# Update with new variables (this will overwrite existing ones with the same name)
for new_env in new_env_vars:
# Find if this environment variable already exists
existing_index = None
for i, existing_env in enumerate(merged_env_vars):
if existing_env['name'] == new_env['name']:
existing_index = i
break
if existing_index is not None:
# Update existing variable
merged_env_vars[existing_index]['value'] = new_env['value']
current_app.logger.info(f"Updated environment variable: {new_env['name']} = {new_env['value']}")
else:
# Add new variable
merged_env_vars.append(new_env)
current_app.logger.info(f"Added new environment variable: {new_env['name']} = {new_env['value']}")
current_app.logger.info(f"Final merged environment variables: {[env['name'] for env in merged_env_vars]}")
# Update the stack using Portainer's update API
update_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
current_app.logger.info(f"Making update request to: {update_url}")
# Prepare the request body for stack update
request_body = {
'StackFileContent': current_stack_file_content, # Use existing stack file content
'Env': merged_env_vars # Use merged environment variables
}
# If new StackFileContent is provided, use it instead
if 'StackFileContent' in data:
request_body['StackFileContent'] = data['StackFileContent']
current_app.logger.info("Using provided StackFileContent for update")
else:
current_app.logger.info("Using existing StackFileContent for update")
# Add endpointId as a query parameter
params = {'endpointId': endpoint_id}
# Use a configurable timeout for stack update initiation
update_response = requests.put(
update_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Content-Type': 'application/json',
'Accept': 'application/json'
},
params=params,
json=request_body,
timeout=stack_timeout # Use configurable timeout
)
# Log the response details
current_app.logger.info(f"Update response status: {update_response.status_code}")
current_app.logger.info(f"Update response headers: {dict(update_response.headers)}")
response_text = update_response.text
current_app.logger.info(f"Update response body: {response_text}")
if not update_response.ok:
error_message = response_text
try:
error_json = update_response.json()
error_message = error_json.get('message', error_message)
except:
pass
return jsonify({'error': f'Failed to update stack: {error_message}'}), 500
# Stack update initiated successfully
current_app.logger.info(f"Stack update initiated: {stack_info['Name']} (ID: {stack_info['Id']})")
return jsonify({
'success': True,
'data': {
'name': stack_info['Name'],
'id': stack_info['Id'],
'status': 'updating'
}
})
except requests.exceptions.Timeout:
current_app.logger.error(f"Request timed out after {stack_timeout} seconds while initiating stack update")
current_app.logger.error(f"Stack ID: {data.get('stack_id', 'unknown') if 'data' in locals() else 'unknown'}")
current_app.logger.error(f"Portainer URL: {portainer_settings.get('url', 'unknown') if 'portainer_settings' in locals() else 'unknown'}")
return jsonify({
'error': f'Request timed out after {stack_timeout} seconds while initiating stack update. The operation may still be in progress.',
'timeout_seconds': stack_timeout,
'stack_id': data.get('stack_id', 'unknown') if 'data' in locals() else 'unknown'
}), 504
except Exception as e:
current_app.logger.error(f"Error updating stack: {str(e)}")
return jsonify({'error': str(e)}), 500

View File

@@ -1,7 +1,8 @@
from flask import render_template, Blueprint, redirect, url_for, request, flash, Response, jsonify, session, current_app from flask import render_template, Blueprint, redirect, url_for, request, flash, Response, jsonify, session, current_app
from flask_login import current_user, login_required from flask_login import current_user, login_required
from models import User, db, Room, RoomFile, RoomMemberPermission, SiteSettings, Event, Conversation, Message, MessageAttachment, Notif, EmailTemplate, Mail, KeyValueSettings, DocuPulseSettings, PasswordSetupToken, Instance, ManagementAPIKey from models import User, db, Room, RoomFile, RoomMemberPermission, SiteSettings, Event, Conversation, Message, MessageAttachment, Notif, EmailTemplate, Mail, KeyValueSettings, DocuPulseSettings, PasswordSetupToken, Instance, ManagementAPIKey, Customer, PricingPlan
from routes.auth import require_password_change from routes.auth import require_password_change
from extensions import csrf
import os import os
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from sqlalchemy import func, case, literal_column, text from sqlalchemy import func, case, literal_column, text
@@ -19,6 +20,8 @@ import smtplib
import requests import requests
from functools import wraps from functools import wraps
import socket import socket
from urllib.parse import urlparse
import stripe
# Set up logging to show in console # Set up logging to show in console
logging.basicConfig( logging.basicConfig(
@@ -491,13 +494,197 @@ def init_routes(main_bp):
return jsonify({'error': 'Unauthorized'}), 403 return jsonify({'error': 'Unauthorized'}), 403
instance = Instance.query.get_or_404(instance_id) instance = Instance.query.get_or_404(instance_id)
# Get Portainer settings
portainer_settings = KeyValueSettings.get_value('portainer_settings')
if not portainer_settings:
current_app.logger.warning(f"Portainer settings not configured, proceeding with database deletion only for instance {instance.name}")
# Continue with database deletion even if Portainer is not configured
try: try:
db.session.delete(instance) db.session.delete(instance)
db.session.commit() db.session.commit()
return jsonify({'message': 'Instance deleted successfully'}) current_app.logger.info(f"Successfully deleted instance from database: {instance.name}")
return jsonify({'message': 'Instance deleted from database (Portainer not configured)'})
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
return jsonify({'error': str(e)}), 400 current_app.logger.error(f"Error deleting instance {instance.name} from database: {str(e)}")
return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500
try:
# First, delete the Portainer stack and its volumes if stack information exists
if instance.portainer_stack_id and instance.portainer_stack_name:
current_app.logger.info(f"Deleting Portainer stack: {instance.portainer_stack_name} (ID: {instance.portainer_stack_id})")
# Get Portainer endpoint ID (assuming it's the first endpoint)
try:
endpoint_response = requests.get(
f"{portainer_settings['url'].rstrip('/')}/api/endpoints",
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
timeout=30
)
if not endpoint_response.ok:
current_app.logger.error(f"Failed to get Portainer endpoints: {endpoint_response.text}")
return jsonify({'error': 'Failed to get Portainer endpoints'}), 500
endpoints = endpoint_response.json()
if not endpoints:
current_app.logger.error("No Portainer endpoints found")
return jsonify({'error': 'No Portainer endpoints found'}), 400
endpoint_id = endpoints[0]['Id']
# Delete the stack (this will also remove associated volumes)
delete_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{instance.portainer_stack_id}"
delete_response = requests.delete(
delete_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'endpointId': endpoint_id},
timeout=60 # Give more time for stack deletion
)
if delete_response.ok:
current_app.logger.info(f"Successfully deleted Portainer stack: {instance.portainer_stack_name}")
else:
current_app.logger.warning(f"Failed to delete Portainer stack: {delete_response.status_code} - {delete_response.text}")
# Continue with database deletion even if Portainer deletion fails
# Also try to delete any orphaned volumes associated with this stack
try:
volumes_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/volumes"
volumes_response = requests.get(
volumes_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
timeout=30
)
if volumes_response.ok:
volumes = volumes_response.json().get('Volumes', [])
stack_volumes = [vol for vol in volumes if vol.get('Labels', {}).get('com.docker.stack.namespace') == instance.portainer_stack_name]
for volume in stack_volumes:
volume_name = volume.get('Name')
if volume_name:
delete_volume_url = f"{volumes_url}/{volume_name}"
volume_delete_response = requests.delete(
delete_volume_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
timeout=30
)
if volume_delete_response.ok:
current_app.logger.info(f"Successfully deleted volume: {volume_name}")
else:
current_app.logger.warning(f"Failed to delete volume {volume_name}: {volume_delete_response.status_code}")
else:
current_app.logger.warning(f"Failed to get volumes list: {volumes_response.status_code}")
except Exception as volume_error:
current_app.logger.warning(f"Error cleaning up volumes: {str(volume_error)}")
except requests.exceptions.RequestException as req_error:
current_app.logger.error(f"Network error during Portainer operations: {str(req_error)}")
# Continue with database deletion even if Portainer operations fail
else:
current_app.logger.info(f"No Portainer stack information found for instance {instance.name}, proceeding with database deletion only")
# Clean up NGINX proxy host if NGINX settings are configured
nginx_settings = KeyValueSettings.get_value('nginx_settings')
if nginx_settings and instance.main_url:
current_app.logger.info(f"Cleaning up NGINX proxy host for instance {instance.name}")
try:
# Extract domain from main_url
parsed_url = urlparse(instance.main_url)
domain = parsed_url.netloc
if domain:
# Get NGINX JWT token
token_response = requests.post(
f"{nginx_settings['url'].rstrip('/')}/api/tokens",
json={
'identity': nginx_settings['username'],
'secret': nginx_settings['password']
},
headers={'Content-Type': 'application/json'},
timeout=30
)
if token_response.ok:
token_data = token_response.json()
token = token_data.get('token')
if token:
# Get all proxy hosts to find the one matching this domain
proxy_hosts_response = requests.get(
f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts",
headers={
'Authorization': f'Bearer {token}',
'Accept': 'application/json'
},
timeout=30
)
if proxy_hosts_response.ok:
proxy_hosts = proxy_hosts_response.json()
# Find proxy host with matching domain
matching_proxy = None
for proxy_host in proxy_hosts:
if proxy_host.get('domain_names') and domain in proxy_host['domain_names']:
matching_proxy = proxy_host
break
if matching_proxy:
# Delete the proxy host
delete_response = requests.delete(
f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts/{matching_proxy['id']}",
headers={
'Authorization': f'Bearer {token}',
'Accept': 'application/json'
},
timeout=30
)
if delete_response.ok:
current_app.logger.info(f"Successfully deleted NGINX proxy host for domain: {domain}")
else:
current_app.logger.warning(f"Failed to delete NGINX proxy host: {delete_response.status_code} - {delete_response.text}")
else:
current_app.logger.info(f"No NGINX proxy host found for domain: {domain}")
else:
current_app.logger.warning(f"Failed to get NGINX proxy hosts: {proxy_hosts_response.status_code}")
else:
current_app.logger.warning("No NGINX token received")
else:
current_app.logger.warning(f"Failed to authenticate with NGINX: {token_response.status_code}")
except Exception as nginx_error:
current_app.logger.warning(f"Error cleaning up NGINX proxy host: {str(nginx_error)}")
# Continue with database deletion even if NGINX cleanup fails
else:
current_app.logger.info(f"No NGINX settings configured or no main_url for instance {instance.name}, skipping NGINX cleanup")
# Now delete the instance from the database
db.session.delete(instance)
db.session.commit()
current_app.logger.info(f"Successfully deleted instance: {instance.name}")
return jsonify({'message': 'Instance and all associated resources (Portainer stack, volumes, NGINX proxy host) deleted successfully'})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error deleting instance {instance.name}: {str(e)}")
return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500
@main_bp.route('/instances/<int:instance_id>/status') @main_bp.route('/instances/<int:instance_id>/status')
@login_required @login_required
@@ -589,6 +776,32 @@ def init_routes(main_bp):
return render_template('main/instance_detail.html', instance=instance) return render_template('main/instance_detail.html', instance=instance)
@main_bp.route('/api/instances/<int:instance_id>')
@login_required
@require_password_change
def get_instance_data(instance_id):
if not os.environ.get('MASTER', 'false').lower() == 'true':
return jsonify({'error': 'Unauthorized'}), 403
instance = Instance.query.get_or_404(instance_id)
return jsonify({
'success': True,
'instance': {
'id': instance.id,
'name': instance.name,
'company': instance.company,
'main_url': instance.main_url,
'status': instance.status,
'payment_plan': instance.payment_plan,
'portainer_stack_id': instance.portainer_stack_id,
'portainer_stack_name': instance.portainer_stack_name,
'deployed_version': instance.deployed_version,
'deployed_branch': instance.deployed_branch,
'connection_token': instance.connection_token
}
})
@main_bp.route('/instances/<int:instance_id>/auth-status') @main_bp.route('/instances/<int:instance_id>/auth-status')
@login_required @login_required
@require_password_change @require_password_change
@@ -1139,6 +1352,7 @@ def init_routes(main_bp):
nginx_settings = KeyValueSettings.get_value('nginx_settings') nginx_settings = KeyValueSettings.get_value('nginx_settings')
git_settings = KeyValueSettings.get_value('git_settings') git_settings = KeyValueSettings.get_value('git_settings')
cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings') cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings')
stripe_settings = KeyValueSettings.get_value('stripe_settings')
# Get management API key for the connections tab # Get management API key for the connections tab
management_api_key = ManagementAPIKey.query.filter_by(is_active=True).first() management_api_key = ManagementAPIKey.query.filter_by(is_active=True).first()
@@ -1216,6 +1430,7 @@ def init_routes(main_bp):
nginx_settings=nginx_settings, nginx_settings=nginx_settings,
git_settings=git_settings, git_settings=git_settings,
cloudflare_settings=cloudflare_settings, cloudflare_settings=cloudflare_settings,
stripe_settings=stripe_settings,
pricing_plans=pricing_plans, pricing_plans=pricing_plans,
csrf_token=generate_csrf()) csrf_token=generate_csrf())
@@ -1914,13 +2129,12 @@ def init_routes(main_bp):
email = data.get('email') email = data.get('email')
api_key = data.get('api_key') api_key = data.get('api_key')
zone_id = data.get('zone_id') zone_id = data.get('zone_id')
server_ip = data.get('server_ip')
if not email or not api_key or not zone_id or not server_ip: if not email or not api_key or not zone_id:
return jsonify({'error': 'Missing required fields'}), 400 return jsonify({'error': 'Missing required fields'}), 400
try: try:
# Test Cloudflare connection by getting zone details # Test Cloudflare connection
headers = { headers = {
'X-Auth-Email': email, 'X-Auth-Email': email,
'X-Auth-Key': api_key, 'X-Auth-Key': api_key,
@@ -1931,21 +2145,77 @@ def init_routes(main_bp):
response = requests.get( response = requests.get(
f'https://api.cloudflare.com/client/v4/zones/{zone_id}', f'https://api.cloudflare.com/client/v4/zones/{zone_id}',
headers=headers, headers=headers,
timeout=10 timeout=5
) )
if response.status_code == 200: if response.status_code == 200:
zone_data = response.json()
if zone_data.get('success'):
return jsonify({'message': 'Connection successful'}) return jsonify({'message': 'Connection successful'})
else: else:
return jsonify({'error': f'API error: {zone_data.get("errors", [{}])[0].get("message", "Unknown error")}'}), 400 return jsonify({'error': f'Connection failed: {response.json().get("errors", [{}])[0].get("message", "Unknown error")}'}), 400
else:
return jsonify({'error': f'Connection failed: HTTP {response.status_code}'}), 400
except Exception as e: except Exception as e:
return jsonify({'error': f'Connection failed: {str(e)}'}), 400 return jsonify({'error': f'Connection failed: {str(e)}'}), 400
@main_bp.route('/settings/save-stripe-connection', methods=['POST'])
@login_required
def save_stripe_connection():
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
data = request.get_json()
publishable_key = data.get('publishable_key')
secret_key = data.get('secret_key')
webhook_secret = data.get('webhook_secret')
test_mode = data.get('test_mode', False)
customer_portal_url = data.get('customer_portal_url', '')
if not publishable_key or not secret_key:
return jsonify({'error': 'Missing required fields'}), 400
try:
# Save Stripe settings
KeyValueSettings.set_value('stripe_settings', {
'publishable_key': publishable_key,
'secret_key': secret_key,
'webhook_secret': webhook_secret,
'test_mode': test_mode,
'customer_portal_url': customer_portal_url
})
return jsonify({'message': 'Settings saved successfully'})
except Exception as e:
return jsonify({'error': str(e)}), 500
@main_bp.route('/settings/test-stripe-connection', methods=['POST'])
@login_required
def test_stripe_connection():
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
data = request.get_json()
secret_key = data.get('secret_key')
if not secret_key:
return jsonify({'error': 'Missing required fields'}), 400
try:
# Test Stripe connection by making a simple API call
import stripe
stripe.api_key = secret_key
# Try to get account information
account = stripe.Account.retrieve()
return jsonify({'message': 'Connection successful'})
except stripe.error.AuthenticationError:
return jsonify({'error': 'Invalid API key'}), 400
except stripe.error.StripeError as e:
return jsonify({'error': f'Stripe error: {str(e)}'}), 400
except Exception as e:
return jsonify({'error': f'Connection failed: {str(e)}'}), 400
@main_bp.route('/instances/launch-progress') @main_bp.route('/instances/launch-progress')
@login_required @login_required
@require_password_change @require_password_change
@@ -1954,6 +2224,12 @@ def init_routes(main_bp):
flash('This page is only available in master instances.', 'error') flash('This page is only available in master instances.', 'error')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
# Get update parameters if this is an update operation
is_update = request.args.get('update', 'false').lower() == 'true'
instance_id = request.args.get('instance_id')
repo_id = request.args.get('repo')
branch = request.args.get('branch')
# Get NGINX settings # Get NGINX settings
nginx_settings = KeyValueSettings.get_value('nginx_settings') nginx_settings = KeyValueSettings.get_value('nginx_settings')
# Get Portainer settings # Get Portainer settings
@@ -1964,7 +2240,11 @@ def init_routes(main_bp):
return render_template('main/launch_progress.html', return render_template('main/launch_progress.html',
nginx_settings=nginx_settings, nginx_settings=nginx_settings,
portainer_settings=portainer_settings, portainer_settings=portainer_settings,
cloudflare_settings=cloudflare_settings) cloudflare_settings=cloudflare_settings,
is_update=is_update,
instance_id=instance_id,
repo_id=repo_id,
branch=branch)
@main_bp.route('/api/check-dns', methods=['POST']) @main_bp.route('/api/check-dns', methods=['POST'])
@login_required @login_required
@@ -2046,6 +2326,13 @@ def init_routes(main_bp):
@login_required @login_required
@require_password_change @require_password_change
def create_dns_records(): def create_dns_records():
"""
Create or update DNS A records in Cloudflare.
Important: DNS records are created with proxied=False to avoid conflicts
with NGINX Proxy Manager. This ensures direct DNS resolution without
Cloudflare's proxy layer interfering with the NGINX configuration.
"""
if not os.environ.get('MASTER', 'false').lower() == 'true': if not os.environ.get('MASTER', 'false').lower() == 'true':
return jsonify({'error': 'Unauthorized'}), 403 return jsonify({'error': 'Unauthorized'}), 403
@@ -2092,7 +2379,7 @@ def init_routes(main_bp):
'name': domain, 'name': domain,
'content': cloudflare_settings['server_ip'], 'content': cloudflare_settings['server_ip'],
'ttl': 1, # Auto TTL 'ttl': 1, # Auto TTL
'proxied': True 'proxied': False # DNS only - no Cloudflare proxy to avoid conflicts with NGINX
} }
update_response = requests.put( update_response = requests.put(
@@ -2113,7 +2400,7 @@ def init_routes(main_bp):
'name': domain, 'name': domain,
'content': cloudflare_settings['server_ip'], 'content': cloudflare_settings['server_ip'],
'ttl': 1, # Auto TTL 'ttl': 1, # Auto TTL
'proxied': True 'proxied': False # DNS only - no Cloudflare proxy to avoid conflicts with NGINX
} }
create_response = requests.post( create_response = requests.post(
@@ -2192,3 +2479,201 @@ def init_routes(main_bp):
'branch': branch, 'branch': branch,
'deployed_at': deployed_at 'deployed_at': deployed_at
}) })
@main_bp.route('/api/create-checkout-session', methods=['POST'])
@csrf.exempt
def create_checkout_session():
"""Create a Stripe checkout session for a pricing plan"""
current_app.logger.info("=== CHECKOUT SESSION REQUEST START ===")
current_app.logger.info(f"Request method: {request.method}")
current_app.logger.info(f"Request headers: {dict(request.headers)}")
current_app.logger.info(f"Request data: {request.get_data()}")
try:
from utils.stripe_utils import create_checkout_session
data = request.get_json()
current_app.logger.info(f"Parsed JSON data: {data}")
plan_id = data.get('plan_id')
billing_cycle = data.get('billing_cycle', 'monthly')
current_app.logger.info(f"Plan ID: {plan_id}")
current_app.logger.info(f"Billing cycle: {billing_cycle}")
if not plan_id:
current_app.logger.error("Plan ID is missing")
return jsonify({'error': 'Plan ID is required'}), 400
if billing_cycle not in ['monthly', 'annual']:
current_app.logger.error(f"Invalid billing cycle: {billing_cycle}")
return jsonify({'error': 'Invalid billing cycle'}), 400
current_app.logger.info("Calling create_checkout_session function...")
# Create checkout session
checkout_url = create_checkout_session(
plan_id=plan_id,
billing_cycle=billing_cycle,
success_url=url_for('main.checkout_success', _external=True),
cancel_url=url_for('main.public_home', _external=True)
)
current_app.logger.info(f"Checkout URL created: {checkout_url}")
response_data = {
'success': True,
'checkout_url': checkout_url
}
current_app.logger.info(f"Returning response: {response_data}")
return jsonify(response_data)
except Exception as e:
current_app.logger.error(f"Error creating checkout session: {str(e)}")
current_app.logger.error(f"Exception type: {type(e)}")
import traceback
current_app.logger.error(f"Traceback: {traceback.format_exc()}")
return jsonify({'error': str(e)}), 500
finally:
current_app.logger.info("=== CHECKOUT SESSION REQUEST END ===")
@main_bp.route('/api/checkout-success')
def checkout_success():
"""Handle successful checkout"""
session_id = request.args.get('session_id')
subscription_info = None
# Get Stripe settings for customer portal link
stripe_settings = KeyValueSettings.get_value('stripe_settings')
if session_id:
try:
from utils.stripe_utils import get_subscription_info
from models import Customer, PricingPlan
subscription_info = get_subscription_info(session_id)
# Log the subscription info for debugging
current_app.logger.info(f"Checkout success - Session ID: {session_id}")
current_app.logger.info(f"Subscription info: {subscription_info}")
# Save or update customer information
if 'customer_details' in subscription_info:
customer_details = subscription_info['customer_details']
current_app.logger.info(f"Customer details: {customer_details}")
# Try to find existing customer by email
customer = Customer.query.filter_by(email=customer_details.get('email')).first()
if customer:
# Update existing customer
current_app.logger.info(f"Updating existing customer: {customer.email}")
else:
# Create new customer
customer = Customer()
current_app.logger.info(f"Creating new customer: {customer_details.get('email')}")
# Update customer information
customer.email = customer_details.get('email')
customer.name = customer_details.get('name')
customer.phone = customer_details.get('phone')
# Update billing address
if customer_details.get('address'):
address = customer_details['address']
customer.billing_address_line1 = address.get('line1')
customer.billing_address_line2 = address.get('line2')
customer.billing_city = address.get('city')
customer.billing_state = address.get('state')
customer.billing_postal_code = address.get('postal_code')
customer.billing_country = address.get('country')
# Update shipping address
if customer_details.get('shipping'):
shipping = customer_details['shipping']
customer.shipping_address_line1 = shipping.get('address', {}).get('line1')
customer.shipping_address_line2 = shipping.get('address', {}).get('line2')
customer.shipping_city = shipping.get('address', {}).get('city')
customer.shipping_state = shipping.get('address', {}).get('state')
customer.shipping_postal_code = shipping.get('address', {}).get('postal_code')
customer.shipping_country = shipping.get('address', {}).get('country')
# Update tax information
if customer_details.get('tax_ids'):
tax_ids = customer_details['tax_ids']
if tax_ids:
# Store the first tax ID (most common case)
customer.tax_id_type = tax_ids[0].get('type')
customer.tax_id_value = tax_ids[0].get('value')
# Update Stripe and subscription information
customer.stripe_customer_id = subscription_info.get('customer_id')
customer.stripe_subscription_id = subscription_info.get('subscription_id')
customer.subscription_status = subscription_info.get('status')
customer.subscription_plan_id = subscription_info.get('plan_id')
customer.subscription_billing_cycle = subscription_info.get('billing_cycle')
customer.subscription_current_period_start = subscription_info.get('current_period_start')
customer.subscription_current_period_end = subscription_info.get('current_period_end')
# Save to database
if not customer.id:
db.session.add(customer)
db.session.commit()
current_app.logger.info(f"Customer saved successfully: {customer.email}")
except Exception as e:
current_app.logger.error(f"Error processing checkout success: {str(e)}")
flash('Payment successful, but there was an issue processing your subscription. Please contact support.', 'warning')
# Render the success page with subscription info and stripe settings
return render_template('checkout_success.html', subscription_info=subscription_info, stripe_settings=stripe_settings)
@main_bp.route('/api/debug/pricing-plans')
@login_required
def debug_pricing_plans():
"""Debug endpoint to check pricing plans"""
try:
from models import PricingPlan
plans = PricingPlan.query.all()
plans_data = []
for plan in plans:
plans_data.append({
'id': plan.id,
'name': plan.name,
'monthly_price': plan.monthly_price,
'annual_price': plan.annual_price,
'stripe_product_id': plan.stripe_product_id,
'stripe_monthly_price_id': plan.stripe_monthly_price_id,
'stripe_annual_price_id': plan.stripe_annual_price_id,
'is_custom': plan.is_custom,
'button_text': plan.button_text
})
return jsonify({
'success': True,
'plans': plans_data,
'count': len(plans_data)
})
except Exception as e:
current_app.logger.error(f"Error getting pricing plans: {str(e)}")
return jsonify({'error': str(e)}), 500
@main_bp.route('/preview-success')
def preview_success():
"""Preview the checkout success page with sample data"""
# Get Stripe settings for customer portal link
stripe_settings = KeyValueSettings.get_value('stripe_settings')
sample_subscription_info = {
'plan_name': 'Professional Plan',
'billing_cycle': 'monthly',
'status': 'active',
'amount': 29.99,
'currency': 'usd'
}
return render_template('checkout_success.html', subscription_info=sample_subscription_info, stripe_settings=stripe_settings)

View File

@@ -57,7 +57,12 @@ def init_public_routes(public_bp):
articles = HelpArticle.get_articles_by_category(category) articles = HelpArticle.get_articles_by_category(category)
category_name = categories[category] category_name = categories[category]
else: else:
# Show all articles when no specific category is requested
articles = [] articles = []
for category_articles in all_articles.values():
articles.extend(category_articles)
# Sort by order_index and then by created_at
articles.sort(key=lambda x: (x.order_index, x.created_at))
category_name = None category_name = None
return render_template('public/help_articles.html', return render_template('public/help_articles.html',

263
static/css/instances.css Normal file
View File

@@ -0,0 +1,263 @@
/* Instances Page Styles */
/* Table Styles */
.table td {
vertical-align: middle;
}
/* Version column styling */
.version-badge {
font-family: monospace;
font-size: 0.85em;
}
.branch-badge {
font-size: 0.85em;
}
/* Make table responsive */
.table-responsive {
overflow-x: auto;
}
/* Tooltip styling for version info */
.tooltip-inner {
max-width: 300px;
text-align: left;
}
/* Version comparison styling */
.version-outdated {
background-color: #fff3cd !important;
border-color: #ffeaa7 !important;
}
.version-current {
background-color: #d1ecf1 !important;
border-color: #bee5eb !important;
}
.badge.bg-orange {
background-color: #fd7e14 !important;
color: white !important;
}
.badge.bg-orange:hover {
background-color: #e55a00 !important;
}
/* Pricing tier selection styles */
.pricing-card {
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.pricing-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-color: var(--primary-color);
}
.pricing-card.selected {
border-color: var(--primary-color);
background-color: rgba(22, 118, 123, 0.05);
box-shadow: 0 4px 12px rgba(22, 118, 123, 0.2);
}
.pricing-card.selected::after {
content: '✓';
position: absolute;
top: 10px;
right: 10px;
background-color: var(--primary-color);
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
.pricing-card.border-primary {
border-color: var(--primary-color) !important;
}
.quota-info {
font-size: 0.75rem;
}
.features {
text-align: left;
}
/* Step Navigation Styles */
.step-item {
text-align: center;
position: relative;
flex: 1;
}
.step-item:not(:last-child)::after {
content: '';
position: absolute;
top: 20px;
right: -50%;
width: 100%;
height: 2px;
background-color: #e9ecef;
z-index: 1;
}
.step-circle {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #e9ecef;
color: #6c757d;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 8px;
position: relative;
z-index: 2;
}
.step-label {
font-size: 0.875rem;
color: #6c757d;
}
.step-item.active .step-circle {
background-color: var(--primary-color);
color: white;
}
.step-item.active .step-label {
color: var(--primary-color);
font-weight: 500;
}
.step-item.completed .step-circle {
background-color: var(--primary-color);
color: white;
}
.step-item.completed:not(:last-child)::after {
background-color: var(--primary-color);
}
.step-pane {
display: none;
}
.step-pane.active {
display: block;
}
/* Connection Check Styles */
.connection-check {
background-color: #f8f9fa;
border-radius: 8px;
padding: 1rem;
}
.connection-status {
font-size: 0.875rem;
}
.connection-status.success {
color: #198754;
}
.connection-status.error {
color: #dc3545;
}
.connection-details {
font-size: 0.875rem;
margin-top: 0.5rem;
}
/* Modal Footer Styles */
.modal-footer {
display: flex;
justify-content: flex-end !important;
gap: 0.5rem;
}
.modal-footer button {
margin-left: 0.5rem;
}
/* Infrastructure Tools Styles */
.infrastructure-tools .btn {
transition: all 0.3s ease;
border-radius: 12px;
min-height: 100px;
text-decoration: none;
}
.infrastructure-tools .btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
text-decoration: none;
}
.infrastructure-tools .btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.infrastructure-tools .btn:disabled:hover {
transform: none;
box-shadow: none;
}
.infrastructure-tools .btn-outline-primary:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
.infrastructure-tools .btn-outline-success:hover {
background-color: #198754;
border-color: #198754;
color: white;
}
.infrastructure-tools .btn-outline-info:hover {
background-color: #0dcaf0;
border-color: #0dcaf0;
color: white;
}
.infrastructure-tools .btn-outline-warning:hover {
background-color: #ffc107;
border-color: #ffc107;
color: #000;
}
.infrastructure-tools .btn-outline-purple {
color: #6f42c1;
border-color: #6f42c1;
}
.infrastructure-tools .btn-outline-purple:hover {
background-color: #6f42c1;
border-color: #6f42c1;
color: #fff;
}
.infrastructure-tools .btn i {
transition: transform 0.3s ease;
}
.infrastructure-tools .btn:hover i {
transform: scale(1.1);
}

1138
static/js/instance_detail.js Normal file

File diff suppressed because it is too large Load Diff

1943
static/js/instances.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -529,6 +529,95 @@ async function saveCloudflareConnection(event) {
saveModal.show(); saveModal.show();
} }
// Save Stripe Connection
async function saveStripeConnection(event) {
event.preventDefault();
const saveModal = new bootstrap.Modal(document.getElementById('saveConnectionModal'));
const messageElement = document.getElementById('saveConnectionMessage');
messageElement.textContent = '';
messageElement.className = '';
try {
const publishableKey = document.getElementById('stripePublishableKey').value;
const secretKey = document.getElementById('stripeSecretKey').value;
const webhookSecret = document.getElementById('stripeWebhookSecret').value;
const customerPortalUrl = document.getElementById('stripeCustomerPortalUrl').value;
const testMode = document.getElementById('stripeTestMode').checked;
if (!publishableKey || !secretKey) {
throw new Error('Please fill in all required fields');
}
const response = await fetch('/settings/save-stripe-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify({
publishable_key: publishableKey,
secret_key: secretKey,
webhook_secret: webhookSecret,
customer_portal_url: customerPortalUrl,
test_mode: testMode
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to save settings');
}
messageElement.textContent = 'Settings saved successfully!';
messageElement.className = 'text-success';
} catch (error) {
messageElement.textContent = error.message || 'Failed to save settings';
messageElement.className = 'text-danger';
}
saveModal.show();
}
// Test Stripe Connection
async function testStripeConnection() {
const saveModal = new bootstrap.Modal(document.getElementById('saveConnectionModal'));
const messageElement = document.getElementById('saveConnectionMessage');
messageElement.textContent = '';
messageElement.className = '';
try {
const secretKey = document.getElementById('stripeSecretKey').value;
if (!secretKey) {
throw new Error('Please enter your Stripe secret key first');
}
const response = await fetch('/settings/test-stripe-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify({
secret_key: secretKey
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Connection test failed');
}
messageElement.textContent = 'Connection test successful!';
messageElement.className = 'text-success';
} catch (error) {
messageElement.textContent = error.message || 'Connection test failed';
messageElement.className = 'text-danger';
}
saveModal.show();
}
// Initialize on page load // Initialize on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const gitSettings = JSON.parse(document.querySelector('meta[name="git-settings"]').getAttribute('content')); const gitSettings = JSON.parse(document.querySelector('meta[name="git-settings"]').getAttribute('content'));

View File

@@ -164,7 +164,9 @@ function loadPlanForEdit(planId) {
document.getElementById('editMonthlyPrice').value = plan.monthly_price; document.getElementById('editMonthlyPrice').value = plan.monthly_price;
document.getElementById('editAnnualPrice').value = plan.annual_price; document.getElementById('editAnnualPrice').value = plan.annual_price;
document.getElementById('editButtonText').value = plan.button_text; document.getElementById('editButtonText').value = plan.button_text;
document.getElementById('editButtonUrl').value = plan.button_url; document.getElementById('stripeProductId').value = plan.stripe_product_id || '';
document.getElementById('stripeMonthlyPriceId').value = plan.stripe_monthly_price_id || '';
document.getElementById('stripeAnnualPriceId').value = plan.stripe_annual_price_id || '';
document.getElementById('editIsPopular').checked = plan.is_popular; document.getElementById('editIsPopular').checked = plan.is_popular;
document.getElementById('editIsCustom').checked = plan.is_custom; document.getElementById('editIsCustom').checked = plan.is_custom;
document.getElementById('editIsActive').checked = plan.is_active; document.getElementById('editIsActive').checked = plan.is_active;

View File

@@ -0,0 +1,149 @@
<div class="row">
<div class="col-md-6">
<h6 class="mb-3">Customer Information</h6>
<table class="table table-sm">
<tr>
<td><strong>Name:</strong></td>
<td>{{ customer.name or 'N/A' }}</td>
</tr>
<tr>
<td><strong>Email:</strong></td>
<td>{{ customer.email }}</td>
</tr>
<tr>
<td><strong>Phone:</strong></td>
<td>{{ customer.phone or 'N/A' }}</td>
</tr>
<tr>
<td><strong>Created:</strong></td>
<td>{{ customer.created_at.strftime('%Y-%m-%d %H:%M') if customer.created_at else 'N/A' }}</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h6 class="mb-3">Subscription Information</h6>
<table class="table table-sm">
<tr>
<td><strong>Plan:</strong></td>
<td>
{% if plan %}
<span class="badge bg-primary">{{ plan.name }}</span>
{% else %}
<span class="text-muted">Plan {{ customer.subscription_plan_id or 'N/A' }}</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>
{% if customer.subscription_status %}
{% if customer.subscription_status == 'active' %}
<span class="badge bg-success">Active</span>
{% elif customer.subscription_status == 'canceled' %}
<span class="badge bg-danger">Canceled</span>
{% elif customer.subscription_status == 'past_due' %}
<span class="badge bg-warning">Past Due</span>
{% else %}
<span class="badge bg-secondary">{{ customer.subscription_status }}</span>
{% endif %}
{% else %}
<span class="text-muted">No subscription</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Billing Cycle:</strong></td>
<td>
{% if customer.subscription_billing_cycle %}
<span class="badge bg-info">{{ customer.subscription_billing_cycle.title() }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Current Period:</strong></td>
<td>
{% if customer.subscription_current_period_start and customer.subscription_current_period_end %}
{{ customer.subscription_current_period_start.strftime('%Y-%m-%d') }} to {{ customer.subscription_current_period_end.strftime('%Y-%m-%d') }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% if customer.billing_address_line1 or customer.shipping_address_line1 %}
<div class="row mt-4">
{% if customer.billing_address_line1 %}
<div class="col-md-6">
<h6 class="mb-3">Billing Address</h6>
<address class="mb-0">
{{ customer.billing_address_line1 }}<br>
{% if customer.billing_address_line2 %}{{ customer.billing_address_line2 }}<br>{% endif %}
{% if customer.billing_city %}{{ customer.billing_city }}{% endif %}
{% if customer.billing_state %}, {{ customer.billing_state }}{% endif %}
{% if customer.billing_postal_code %} {{ customer.billing_postal_code }}{% endif %}<br>
{% if customer.billing_country %}{{ customer.billing_country }}{% endif %}
</address>
</div>
{% endif %}
{% if customer.shipping_address_line1 %}
<div class="col-md-6">
<h6 class="mb-3">Shipping Address</h6>
<address class="mb-0">
{{ customer.shipping_address_line1 }}<br>
{% if customer.shipping_address_line2 %}{{ customer.shipping_address_line2 }}<br>{% endif %}
{% if customer.shipping_city %}{{ customer.shipping_city }}{% endif %}
{% if customer.shipping_state %}, {{ customer.shipping_state }}{% endif %}
{% if customer.shipping_postal_code %} {{ customer.shipping_postal_code }}{% endif %}<br>
{% if customer.shipping_country %}{{ customer.shipping_country }}{% endif %}
</address>
</div>
{% endif %}
</div>
{% endif %}
{% if customer.tax_id_type and customer.tax_id_value %}
<div class="row mt-4">
<div class="col-12">
<h6 class="mb-3">Tax Information</h6>
<table class="table table-sm">
<tr>
<td><strong>Tax ID Type:</strong></td>
<td>{{ customer.tax_id_type }}</td>
</tr>
<tr>
<td><strong>Tax ID Value:</strong></td>
<td>{{ customer.tax_id_value }}</td>
</tr>
</table>
</div>
</div>
{% endif %}
{% if customer.stripe_customer_id or customer.stripe_subscription_id %}
<div class="row mt-4">
<div class="col-12">
<h6 class="mb-3">Stripe Information</h6>
<table class="table table-sm">
{% if customer.stripe_customer_id %}
<tr>
<td><strong>Stripe Customer ID:</strong></td>
<td><code>{{ customer.stripe_customer_id }}</code></td>
</tr>
{% endif %}
{% if customer.stripe_subscription_id %}
<tr>
<td><strong>Stripe Subscription ID:</strong></td>
<td><code>{{ customer.stripe_subscription_id }}</code></td>
</tr>
{% endif %}
</table>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,178 @@
{% extends "common/base.html" %}
{% from "components/header.html" import header %}
{% block title %}Customers - Admin{% endblock %}
{% block content %}
{{ header(
title="Customers",
description="Manage customer information and subscriptions",
icon="fa-users"
) }}
<div class="container-fluid">
{% if customers.items %}
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Phone</th>
<th>Plan</th>
<th>Status</th>
<th>Billing Cycle</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for customer in customers.items %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm me-3">
<div class="avatar-title bg-primary rounded-circle">
{{ customer.name[0] if customer.name else customer.email[0] }}
</div>
</div>
<div>
<h6 class="mb-0">{{ customer.name or 'N/A' }}</h6>
</div>
</div>
</td>
<td>{{ customer.email }}</td>
<td>{{ customer.phone or 'N/A' }}</td>
<td>
{% if customer.subscription_plan_id %}
{% set plan = customer.plan %}
{% if plan %}
<span class="badge bg-primary">{{ plan.name }}</span>
{% else %}
<span class="text-muted">Plan {{ customer.subscription_plan_id }}</span>
{% endif %}
{% else %}
<span class="text-muted">No plan</span>
{% endif %}
</td>
<td>
{% if customer.subscription_status %}
{% if customer.subscription_status == 'active' %}
<span class="badge bg-success">Active</span>
{% elif customer.subscription_status == 'canceled' %}
<span class="badge bg-danger">Canceled</span>
{% elif customer.subscription_status == 'past_due' %}
<span class="badge bg-warning">Past Due</span>
{% else %}
<span class="badge bg-secondary">{{ customer.subscription_status }}</span>
{% endif %}
{% else %}
<span class="text-muted">No subscription</span>
{% endif %}
</td>
<td>
{% if customer.subscription_billing_cycle %}
<span class="badge bg-info">{{ customer.subscription_billing_cycle.title() }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>{{ customer.created_at.strftime('%Y-%m-%d') if customer.created_at else 'N/A' }}</td>
<td>
<button class="btn btn-sm btn-outline-primary"
onclick="viewCustomerDetails({{ customer.id }})">
<i class="fas fa-eye"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if customers.pages > 1 %}
<nav aria-label="Customer pagination">
<ul class="pagination justify-content-center">
{% if customers.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.customers', page=customers.prev_num) }}">Previous</a>
</li>
{% endif %}
{% for page_num in customers.iter_pages() %}
{% if page_num %}
{% if page_num != customers.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.customers', page=page_num) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if customers.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.customers', page=customers.next_num) }}">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
</div>
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No customers found</h5>
<p class="text-muted">Customers will appear here once they complete a purchase.</p>
</div>
</div>
{% endif %}
</div>
<!-- Customer Details Modal -->
<div class="modal fade" id="customerDetailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Customer Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="customerDetailsContent">
<!-- Content will be loaded here -->
</div>
</div>
</div>
</div>
<script>
function viewCustomerDetails(customerId) {
// Load customer details via AJAX
fetch(`/admin/customers/${customerId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('customerDetailsContent').innerHTML = data.html;
new bootstrap.Modal(document.getElementById('customerDetailsModal')).show();
} else {
alert('Failed to load customer details');
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to load customer details');
});
}
</script>
{% endblock %}

View File

@@ -1,7 +1,12 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% from "components/header.html" import header %}
{% block title %}Support Articles - DocuPulse{% endblock %} {% block title %}Support Articles - DocuPulse{% endblock %}
{% block head %}
<meta name="csrf-token" content="{{ csrf_token }}">
{% endblock %}
{% block extra_css %} {% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
<style> <style>
@@ -65,18 +70,24 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{ header(
title="Support Articles",
description="Create and manage help articles for users",
icon="fa-life-ring",
buttons=[
{
'text': 'Create New Article',
'url': '#',
'onclick': 'showCreateArticleModal()',
'icon': 'fa-plus',
'class': 'btn-primary'
}
]
) }}
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="color: var(--primary-color);">
<i class="fas fa-life-ring me-2"></i>Support Articles
</h1>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createArticleModal">
<i class="fas fa-plus me-2"></i>Create New Article
</button>
</div>
<!-- Articles List --> <!-- Articles List -->
<div class="row" id="articlesList"> <div class="row" id="articlesList">
<!-- Articles will be loaded here via AJAX --> <!-- Articles will be loaded here via AJAX -->
@@ -227,6 +238,7 @@
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete this article? This action cannot be undone.</p> <p>Are you sure you want to delete this article? This action cannot be undone.</p>
<p class="text-muted" id="deleteArticleTitle"></p> <p class="text-muted" id="deleteArticleTitle"></p>
<input type="hidden" id="deleteArticleId" value="">
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -295,7 +307,15 @@ document.addEventListener('DOMContentLoaded', function() {
function loadArticles() { function loadArticles() {
fetch('/api/admin/help-articles') fetch('/api/admin/help-articles')
.then(response => response.json()) .then(response => {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return response.text().then(text => {
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
});
}
return response.json();
})
.then(data => { .then(data => {
const articlesList = document.getElementById('articlesList'); const articlesList = document.getElementById('articlesList');
articlesList.innerHTML = ''; articlesList.innerHTML = '';
@@ -377,7 +397,17 @@ function createArticle() {
method: 'POST', method: 'POST',
body: formData body: formData
}) })
.then(response => response.json()) .then(response => {
// Check if response is JSON
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
// If not JSON, get the text and throw an error
return response.text().then(text => {
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
});
}
return response.json();
})
.then(data => { .then(data => {
if (data.success) { if (data.success) {
const modal = bootstrap.Modal.getInstance(document.getElementById('createArticleModal')); const modal = bootstrap.Modal.getInstance(document.getElementById('createArticleModal'));
@@ -398,7 +428,15 @@ function createArticle() {
function editArticle(articleId) { function editArticle(articleId) {
fetch(`/api/admin/help-articles/${articleId}`) fetch(`/api/admin/help-articles/${articleId}`)
.then(response => response.json()) .then(response => {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return response.text().then(text => {
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
});
}
return response.json();
})
.then(data => { .then(data => {
document.getElementById('editArticleId').value = data.article.id; document.getElementById('editArticleId').value = data.article.id;
document.getElementById('editArticleTitle').value = data.article.title; document.getElementById('editArticleTitle').value = data.article.title;
@@ -426,7 +464,15 @@ function updateArticle() {
method: 'PUT', method: 'PUT',
body: formData body: formData
}) })
.then(response => response.json()) .then(response => {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return response.text().then(text => {
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
});
}
return response.json();
})
.then(data => { .then(data => {
if (data.success) { if (data.success) {
const modal = bootstrap.Modal.getInstance(document.getElementById('editArticleModal')); const modal = bootstrap.Modal.getInstance(document.getElementById('editArticleModal'));
@@ -457,7 +503,15 @@ function deleteArticle() {
fetch(`/api/admin/help-articles/${articleId}`, { fetch(`/api/admin/help-articles/${articleId}`, {
method: 'DELETE' method: 'DELETE'
}) })
.then(response => response.json()) .then(response => {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return response.text().then(text => {
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
});
}
return response.json();
})
.then(data => { .then(data => {
if (data.success) { if (data.success) {
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteArticleModal')); const modal = bootstrap.Modal.getInstance(document.getElementById('deleteArticleModal'));

View File

@@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Successful - DocuPulse</title>
<meta name="description" content="Your DocuPulse subscription has been activated successfully. Welcome to the future of document management.">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.hero-section {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
padding: 120px 0 80px 0;
}
.success-card {
border: none;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
background: var(--white);
overflow: hidden;
}
.success-card .card-header {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
border: none;
padding: 1.5rem;
font-weight: 600;
font-size: 1.1rem;
}
.success-card .card-body {
padding: 2rem;
}
.subscription-detail {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
}
.subscription-detail:last-child {
border-bottom: none;
}
.subscription-detail strong {
color: var(--text-dark);
font-weight: 600;
}
.next-step-item {
display: flex;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
}
.next-step-item:last-child {
border-bottom: none;
}
.next-step-item i {
width: 24px;
margin-right: 0.75rem;
color: var(--primary-color);
}
.next-step-item a {
color: var(--text-dark);
text-decoration: none;
transition: color 0.2s ease;
}
.next-step-item a:hover {
color: var(--primary-color);
}
.feature-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
margin-top: 1rem;
}
.feature-item {
text-align: center;
padding: 1.25rem;
background: var(--bg-color);
border-radius: 12px;
}
.feature-icon {
width: 60px;
height: 60px;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
margin: 0 auto 1rem;
font-size: 1.25rem;
box-shadow: 0 4px 12px rgba(var(--primary-color-rgb), 0.3);
}
.feature-item h6 {
font-weight: 600;
color: var(--text-dark);
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.feature-item p {
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.4;
margin: 0;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
border: none;
border-radius: 25px;
padding: 12px 30px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
transform: translateY(-2px);
box-shadow: 0 5px 15px var(--primary-opacity-15);
filter: brightness(1.1);
}
.btn-outline-primary {
border: 2px solid var(--primary-color);
color: var(--primary-color);
border-radius: 25px;
padding: 12px 30px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-outline-primary:hover {
background: rgba(var(--primary-color-rgb), 0.05);
border-color: var(--primary-color);
color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
}
.section-title {
color: var(--text-dark);
font-weight: 700;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
}
.section-title i {
margin-right: 0.75rem;
color: var(--primary-color);
}
</style>
</head>
<body>
{% include 'components/header_nav.html' %}
<!-- Hero Section -->
<section class="hero-section">
<div class="container">
<div class="row justify-content-center text-center">
<div class="col-lg-8">
<div class="mb-4">
<i class="fas fa-check-circle" style="font-size: 4rem;"></i>
</div>
<h1 class="display-4 fw-bold mb-3">Payment Successful!</h1>
<p class="lead mb-4">Your subscription has been activated and your DocuPulse instance is being set up.</p>
</div>
</div>
</div>
</section>
<!-- Success Details Section -->
<section class="py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="row">
<div class="col-md-6 mb-4">
<div class="card success-card h-100">
<div class="card-header">
<i class="fas fa-check-circle me-2"></i>
Payment Confirmation
</div>
<div class="card-body">
{% if subscription_info %}
<div class="row">
<div class="col-12">
<h5 class="section-title">
<i class="fas fa-receipt"></i>
Subscription Details
</h5>
<div class="subscription-detail">
<strong>Plan:</strong> {{ subscription_info.plan_name }}
</div>
<div class="subscription-detail">
<strong>Billing Cycle:</strong> {{ subscription_info.billing_cycle.title() }}
</div>
<div class="subscription-detail">
<strong>Status:</strong>
<span class="badge bg-success">{{ subscription_info.status.title() }}</span>
</div>
<div class="subscription-detail">
<strong>Amount:</strong> ${{ "%.2f"|format(subscription_info.amount) }} {{ subscription_info.currency.upper() }}
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<h5 class="section-title">
<i class="fas fa-rocket"></i>
Next Steps
</h5>
<div class="next-step-item">
<i class="fas fa-envelope"></i>
<span>Check your email for login credentials</span>
</div>
<div class="next-step-item">
<i class="fas fa-book"></i>
<a href="{{ url_for('public.help_center') }}">Read our getting started guide</a>
</div>
{% if stripe_settings and stripe_settings.customer_portal_url %}
<div class="next-step-item">
<i class="fas fa-credit-card"></i>
<a href="{{ stripe_settings.customer_portal_url }}" target="_blank">Manage your subscription & billing</a>
</div>
{% endif %}
<div class="next-step-item">
<i class="fas fa-headset"></i>
<a href="{{ url_for('public.contact') }}">Contact support if you need help</a>
</div>
</div>
</div>
{% else %}
<div class="text-center">
<h5 class="mb-3">Thank you for your purchase!</h5>
<p class="text-muted">Your payment has been processed successfully.</p>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card success-card h-100">
<div class="card-header">
<i class="fas fa-info-circle me-2"></i>
What happens next?
</div>
<div class="card-body">
<div class="feature-grid">
<div class="feature-item">
<div class="feature-icon">
<i class="fas fa-rocket"></i>
</div>
<h6>Instance Setup</h6>
<p>Your DocuPulse instance will be automatically provisioned within the next few minutes.</p>
</div>
<div class="feature-item">
<div class="feature-icon">
<i class="fas fa-envelope"></i>
</div>
<h6>Welcome Email</h6>
<p>You'll receive an email with your login credentials and setup instructions.</p>
</div>
<div class="feature-item">
<div class="feature-icon">
<i class="fas fa-headset"></i>
</div>
<h6>Support Available</h6>
<p>Our support team is ready to help you get started with DocuPulse.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="text-center mt-4">
<a href="{{ url_for('public.help_center') }}" class="btn btn-primary btn-lg me-3">
<i class="fas fa-question-circle me-2"></i>Get Help
</a>
<a href="{{ url_for('public.contact') }}" class="btn btn-outline-primary btn-lg">
<i class="fas fa-envelope me-2"></i>Contact Support
</a>
</div>
</div>
</div>
</div>
</section>
{% include 'components/footer_nav.html' %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -63,11 +63,9 @@
<li><a class="dropdown-item" href="{{ url_for('main.settings') }}"><i class="fas fa-cog"></i> Settings</a></li> <li><a class="dropdown-item" href="{{ url_for('main.settings') }}"><i class="fas fa-cog"></i> Settings</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% else %} {% else %}
{% if not is_master %}
<li><a class="dropdown-item" href="{{ url_for('main.profile') }}"><i class="fas fa-user"></i> Profile</a></li> <li><a class="dropdown-item" href="{{ url_for('main.profile') }}"><i class="fas fa-user"></i> Profile</a></li>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<li><a class="dropdown-item" href="{{ url_for('main.settings') }}"><i class="fas fa-cog"></i> Settings</a></li> <li><a class="dropdown-item" href="{{ url_for('main.settings') }}"><i class="fas fa-cog"></i> Settings</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -105,6 +103,11 @@
<i class="fas fa-life-ring"></i> Support Articles <i class="fas fa-life-ring"></i> Support Articles
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.customers' %}active{% endif %}" href="{{ url_for('admin.customers') }}">
<i class="fas fa-users"></i> Customers
</a>
</li>
{% endif %} {% endif %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}"> <a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}">
@@ -153,6 +156,18 @@
<i class="fas fa-user"></i> Profile <i class="fas fa-user"></i> Profile
</a> </a>
</li> </li>
{% else %}
<li class="nav-item d-lg-none">
<hr class="my-2">
<a class="nav-link" href="{{ url_for('main.settings') }}">
<i class="fas fa-cog"></i> Settings
</a>
</li>
<li class="nav-item d-lg-none">
<a class="nav-link" href="{{ url_for('main.profile') }}">
<i class="fas fa-user"></i> Profile
</a>
</li>
{% endif %} {% endif %}
<li class="nav-item d-lg-none"> <li class="nav-item d-lg-none">
<a class="nav-link" href="{{ url_for('auth.logout') }}"> <a class="nav-link" href="{{ url_for('auth.logout') }}">

View File

@@ -8,6 +8,19 @@
{% set pricing_plans = PricingPlan.get_active_plans() %} {% set pricing_plans = PricingPlan.get_active_plans() %}
{% if pricing_plans %} {% if pricing_plans %}
<!-- Debug info -->
<div style="display: none;" id="pricing-debug">
<h4>Debug: Pricing Plans Found</h4>
{% for plan in pricing_plans %}
<div>
Plan: {{ plan.name }} (ID: {{ plan.id }})
- Monthly Price ID: {{ plan.stripe_monthly_price_id or 'None' }}
- Annual Price ID: {{ plan.stripe_annual_price_id or 'None' }}
- Is Custom: {{ plan.is_custom }}
</div>
{% endfor %}
</div>
<div class="row g-4 justify-content-center"> <div class="row g-4 justify-content-center">
{% for plan in pricing_plans %} {% for plan in pricing_plans %}
<div class="col-md-3"> <div class="col-md-3">
@@ -47,9 +60,28 @@
</ul> </ul>
</div> </div>
<a href="{{ plan.button_url }}" class="btn {% if plan.is_popular %}btn-primary{% else %}btn-outline-primary{% endif %} btn-lg w-100 mt-auto px-4 py-3"> <!-- Dynamic Payment Button -->
{% if plan.is_custom %}
<a href="{{ contact_url }}" class="btn {% if plan.is_popular %}btn-primary{% else %}btn-outline-primary{% endif %} btn-lg w-100 mt-auto px-4 py-3">
{{ plan.button_text }} {{ plan.button_text }}
</a> </a>
{% else %}
{% if plan.stripe_monthly_price_id or plan.stripe_annual_price_id %}
<button class="btn {% if plan.is_popular %}btn-primary{% else %}btn-outline-primary{% endif %} btn-lg w-100 mt-auto px-4 py-3 checkout-button"
data-plan-id="{{ plan.id }}"
data-monthly-product-id="{{ plan.stripe_monthly_price_id or '' }}"
data-annual-product-id="{{ plan.stripe_annual_price_id or '' }}"
data-plan-name="{{ plan.name }}"
data-monthly-price="{{ plan.monthly_price or 0 }}"
data-annual-price="{{ plan.annual_price or 0 }}">
{{ plan.button_text }}
</button>
{% else %}
<a href="{{ contact_url }}" class="btn {% if plan.is_popular %}btn-primary{% else %}btn-outline-primary{% endif %} btn-lg w-100 mt-auto px-4 py-3">
{{ plan.button_text }}
</a>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -80,7 +112,7 @@
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);"> <div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
<span class="monthly-price">€29</span> <span class="monthly-price">€29</span>
<span class="annual-price" style="display: none;">€23</span> <span class="annual-price" style="display: none;">€23</span>
<span class="fs-6 text-muted">/month</span> <span class="fs-6 text-muted price-period">/month</span>
</div> </div>
<ul class="list-unstyled mb-4"> <ul class="list-unstyled mb-4">
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 5 rooms</li> <li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 5 rooms</li>
@@ -107,7 +139,7 @@
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);"> <div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
<span class="monthly-price">€99</span> <span class="monthly-price">€99</span>
<span class="annual-price" style="display: none;">€79</span> <span class="annual-price" style="display: none;">€79</span>
<span class="fs-6 text-muted">/month</span> <span class="fs-6 text-muted price-period">/month</span>
</div> </div>
<ul class="list-unstyled mb-4"> <ul class="list-unstyled mb-4">
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 25 rooms</li> <li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 25 rooms</li>
@@ -129,7 +161,7 @@
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);"> <div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
<span class="monthly-price">€299</span> <span class="monthly-price">€299</span>
<span class="annual-price" style="display: none;">€239</span> <span class="annual-price" style="display: none;">€239</span>
<span class="fs-6 text-muted">/month</span> <span class="fs-6 text-muted price-period">/month</span>
</div> </div>
<ul class="list-unstyled mb-4"> <ul class="list-unstyled mb-4">
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 100 rooms</li> <li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 100 rooms</li>
@@ -179,11 +211,31 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Debug: Log pricing plans info
console.log('=== PRICING DEBUG INFO ===');
const checkoutButtons = document.querySelectorAll('.checkout-button');
console.log('Found checkout buttons:', checkoutButtons.length);
checkoutButtons.forEach((button, index) => {
console.log(`Button ${index + 1}:`, {
planId: button.getAttribute('data-plan-id'),
monthlyProductId: button.getAttribute('data-monthly-product-id'),
annualProductId: button.getAttribute('data-annual-product-id'),
planName: button.getAttribute('data-plan-name'),
monthlyPrice: button.getAttribute('data-monthly-price'),
annualPrice: button.getAttribute('data-annual-price')
});
});
// Show debug info if needed (uncomment to show)
// document.getElementById('pricing-debug').style.display = 'block';
const billingToggle = document.getElementById('annualBilling'); const billingToggle = document.getElementById('annualBilling');
if (!billingToggle) return; if (!billingToggle) return;
const monthlyPrices = document.querySelectorAll('.monthly-price'); const monthlyPrices = document.querySelectorAll('.monthly-price');
const annualPrices = document.querySelectorAll('.annual-price'); const annualPrices = document.querySelectorAll('.annual-price');
const pricePeriods = document.querySelectorAll('.price-period');
// Add CSS for switch styling // Add CSS for switch styling
const style = document.createElement('style'); const style = document.createElement('style');
@@ -229,6 +281,89 @@ document.addEventListener('DOMContentLoaded', function() {
requestAnimationFrame(updateNumber); requestAnimationFrame(updateNumber);
} }
// Function to handle Stripe checkout
async function handleCheckout(planId, billingCycle) {
console.log('handleCheckout called with:', { planId, billingCycle });
try {
const requestBody = {
plan_id: planId,
billing_cycle: billingCycle
};
console.log('Sending request to /api/create-checkout-session with body:', requestBody);
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
console.log('Response status:', response.status);
console.log('Response ok:', response.ok);
if (!response.ok) {
const errorText = await response.text();
console.error('Response error text:', errorText);
throw new Error(`Failed to create checkout session: ${response.status} ${errorText}`);
}
const data = await response.json();
console.log('Response data:', data);
if (data.checkout_url) {
console.log('Redirecting to checkout URL:', data.checkout_url);
window.location.href = data.checkout_url;
} else {
console.error('No checkout URL received in response');
alert('Failed to create checkout session. Please try again.');
}
} catch (error) {
console.error('Checkout error:', error);
alert('Failed to start checkout. Please try again.');
}
}
// Add click handlers for checkout buttons
document.querySelectorAll('.checkout-button').forEach(button => {
console.log('Adding click handler to checkout button:', button);
button.addEventListener('click', function() {
console.log('Checkout button clicked!');
console.log('Button data attributes:', {
planId: this.getAttribute('data-plan-id'),
monthlyProductId: this.getAttribute('data-monthly-product-id'),
annualProductId: this.getAttribute('data-annual-product-id'),
planName: this.getAttribute('data-plan-name'),
monthlyPrice: this.getAttribute('data-monthly-price'),
annualPrice: this.getAttribute('data-annual-price')
});
const planId = this.getAttribute('data-plan-id');
const monthlyProductId = this.getAttribute('data-monthly-product-id');
const annualProductId = this.getAttribute('data-annual-product-id');
const planName = this.getAttribute('data-plan-name');
const monthlyPrice = parseFloat(this.getAttribute('data-monthly-price'));
const annualPrice = parseFloat(this.getAttribute('data-annual-price'));
// Determine which billing cycle to use based on billing toggle
const isAnnual = billingToggle.checked;
const billingCycle = isAnnual ? 'annual' : 'monthly';
console.log('Billing toggle state:', { isAnnual, billingCycle });
console.log('Plan ID:', planId);
if (planId) {
console.log('Calling handleCheckout with planId and billingCycle');
handleCheckout(planId, billingCycle);
} else {
console.log('No plan ID found, redirecting to contact form');
// Fallback to contact form if no plan configured
window.location.href = '{{ contact_url }}';
}
});
});
billingToggle.addEventListener('change', function() { billingToggle.addEventListener('change', function() {
if (this.checked) { if (this.checked) {
// Switch to annual prices with animation // Switch to annual prices with animation
@@ -242,6 +377,13 @@ document.addEventListener('DOMContentLoaded', function() {
// Simply animate the number change // Simply animate the number change
animateNumber(price, monthlyValue, annualValue); animateNumber(price, monthlyValue, annualValue);
}); });
// Update price periods to show "/year"
document.querySelectorAll('.fs-6.text-muted, .price-period').forEach(period => {
if (period.textContent.includes('/month')) {
period.textContent = '/year';
}
});
} else { } else {
// Switch to monthly prices with animation // Switch to monthly prices with animation
monthlyPrices.forEach((price, index) => { monthlyPrices.forEach((price, index) => {
@@ -251,8 +393,14 @@ document.addEventListener('DOMContentLoaded', function() {
// Simply animate the number change back to monthly // Simply animate the number change back to monthly
animateNumber(price, currentValue, originalMonthlyValue); animateNumber(price, currentValue, originalMonthlyValue);
}); });
// Update price periods to show "/month"
document.querySelectorAll('.fs-6.text-muted, .price-period').forEach(period => {
if (period.textContent.includes('/year')) {
period.textContent = '/month';
}
});
} }
}); });
}); });
</script> </script>
</script>

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<link rel="stylesheet" href="{{ url_for('static', filename='css/home.css') }}?v={{ 'css/home.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/home.css') }}?v={{ 'css/home.css'|asset_version }}">
<style> <style>
.admin-link { .admin-link {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,9 @@
{% block content %} {% block content %}
{{ header( {{ header(
title="Launching Instance", title=is_update and "Updating Instance" or "Launching Instance",
description="Setting up your new DocuPulse instance", description=is_update and "Updating your DocuPulse instance with the latest version" or "Setting up your new DocuPulse instance",
icon="fa-rocket" icon="fa-arrow-up" if is_update else "fa-rocket"
) }} ) }}
<div class="container-fluid"> <div class="container-fluid">
@@ -78,6 +78,12 @@
// Pass CSRF token to JavaScript // Pass CSRF token to JavaScript
window.csrfToken = '{{ csrf_token }}'; window.csrfToken = '{{ csrf_token }}';
// Pass update parameters if this is an update operation
window.isUpdate = {{ 'true' if is_update else 'false' }};
window.updateInstanceId = '{{ instance_id or "" }}';
window.updateRepoId = '{{ repo_id or "" }}';
window.updateBranch = '{{ branch or "" }}';
</script> </script>
<script src="{{ url_for('static', filename='js/launch_progress.js') }}"></script> <script src="{{ url_for('static', filename='js/launch_progress.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.about-section { .about-section {
padding: 80px 0; padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.careers-section { .careers-section {
padding: 80px 0; padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.legal-section { .legal-section {
padding: 80px 0; padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.contact-section { .contact-section {
padding: 80px 0; padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
/* Enhanced Features Page Styles */ /* Enhanced Features Page Styles */
.hero-section { .hero-section {

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.legal-section { .legal-section {
padding: 80px 0; padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.help-section { .help-section {
padding: 80px 0; padding: 80px 0;
@@ -415,23 +416,5 @@
{% include 'components/footer_nav.html' %} {% include 'components/footer_nav.html' %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Search functionality
document.querySelector('.search-box').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const faqItems = document.querySelectorAll('.faq-item');
faqItems.forEach(item => {
const question = item.querySelector('.faq-question').textContent.toLowerCase();
const answer = item.querySelector('.faq-answer').textContent.toLowerCase();
if (question.includes(searchTerm) || answer.includes(searchTerm)) {
item.style.display = 'block';
} else {
item.style.display = 'none';
}
});
});
</script>
</body> </body>
</html> </html>

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.category-nav { .category-nav {
background: var(--white); background: var(--white);

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.press-section { .press-section {
padding: 80px 0; padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.pricing-section { .pricing-section {
padding: 80px 0; padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.legal-section { .legal-section {
padding: 80px 0; padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.security-section { .security-section {
padding: 80px 0; padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style> <style>
.legal-section { .legal-section {
padding: 80px 0; padding: 80px 0;

View File

@@ -140,7 +140,7 @@
{% if is_master %} {% if is_master %}
<!-- Connections Tab --> <!-- Connections Tab -->
<div class="tab-pane fade {% if active_tab == 'connections' %}show active{% endif %}" id="connections" role="tabpanel" aria-labelledby="connections-tab"> <div class="tab-pane fade {% if active_tab == 'connections' %}show active{% endif %}" id="connections" role="tabpanel" aria-labelledby="connections-tab">
{{ connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings) }} {{ connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings, stripe_settings) }}
</div> </div>
<!-- Pricing Tab --> <!-- Pricing Tab -->

View File

@@ -1,6 +1,6 @@
{% from "settings/components/connection_modals.html" import connection_modals %} {% from "settings/components/connection_modals.html" import connection_modals %}
{% macro connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings) %} {% macro connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings, stripe_settings) %}
<!-- Meta tags for JavaScript --> <!-- Meta tags for JavaScript -->
<meta name="management-api-key" content="{{ site_settings.management_api_key }}"> <meta name="management-api-key" content="{{ site_settings.management_api_key }}">
<meta name="git-settings" content="{{ git_settings|tojson|safe }}"> <meta name="git-settings" content="{{ git_settings|tojson|safe }}">
@@ -212,6 +212,67 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Stripe Connection Card -->
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fab fa-stripe me-2"></i>Stripe Connection
</h5>
<button class="btn btn-sm btn-outline-primary" onclick="testStripeConnection()">
<i class="fas fa-plug me-1"></i>Test Connection
</button>
</div>
<div class="card-body">
<form id="stripeForm" onsubmit="saveStripeConnection(event)">
<div class="mb-3">
<label for="stripePublishableKey" class="form-label">Publishable Key</label>
<input type="text" class="form-control" id="stripePublishableKey" name="stripePublishableKey"
placeholder="pk_test_..." required
value="{{ stripe_settings.publishable_key if stripe_settings and stripe_settings.publishable_key else '' }}">
<div class="form-text">Your Stripe publishable key (starts with pk_test_ or pk_live_)</div>
</div>
<div class="mb-3">
<label for="stripeSecretKey" class="form-label">Secret Key</label>
<input type="password" class="form-control" id="stripeSecretKey" name="stripeSecretKey"
placeholder="sk_test_..." required
value="{{ stripe_settings.secret_key if stripe_settings and stripe_settings.secret_key else '' }}">
<div class="form-text">Your Stripe secret key (starts with sk_test_ or sk_live_)</div>
</div>
<div class="mb-3">
<label for="stripeWebhookSecret" class="form-label">Webhook Secret (Optional)</label>
<input type="password" class="form-control" id="stripeWebhookSecret" name="stripeWebhookSecret"
placeholder="whsec_..."
value="{{ stripe_settings.webhook_secret if stripe_settings and stripe_settings.webhook_secret else '' }}">
<div class="form-text">Webhook endpoint secret for secure event handling</div>
</div>
<div class="mb-3">
<label for="stripeCustomerPortalUrl" class="form-label">Customer Portal URL</label>
<input type="url" class="form-control" id="stripeCustomerPortalUrl" name="stripeCustomerPortalUrl"
placeholder="https://billing.stripe.com/p/login/..."
value="{{ stripe_settings.customer_portal_url if stripe_settings and stripe_settings.customer_portal_url else '' }}">
<div class="form-text">URL for customers to manage their subscriptions and billing</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="stripeTestMode" name="stripeTestMode"
{% if stripe_settings and stripe_settings.test_mode %}checked{% endif %}>
<label class="form-check-label" for="stripeTestMode">
Test Mode (Use test keys)
</label>
</div>
<div class="form-text">Enable this to use Stripe test mode for development</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>Save Stripe Settings
</button>
</div>
</form>
</div>
</div>
</div>
</div> </div>
<!-- Save Connection Modal --> <!-- Save Connection Modal -->

View File

@@ -53,6 +53,39 @@
</div> </div>
</div> </div>
<!-- Stripe Integration Info -->
{% if plan.stripe_product_id or plan.stripe_monthly_price_id or plan.stripe_annual_price_id %}
<div class="mb-3">
<strong>Stripe Integration:</strong>
<div class="mt-2">
{% if plan.stripe_product_id %}
<div class="mb-1">
<small class="text-muted">
<i class="fas fa-tag me-1"></i>Product ID:
<code class="text-primary">{{ plan.stripe_product_id }}</code>
</small>
</div>
{% endif %}
{% if plan.stripe_monthly_price_id %}
<div class="mb-1">
<small class="text-muted">
<i class="fas fa-credit-card me-1"></i>Monthly Price ID:
<code class="text-primary">{{ plan.stripe_monthly_price_id }}</code>
</small>
</div>
{% endif %}
{% if plan.stripe_annual_price_id %}
<div class="mb-1">
<small class="text-muted">
<i class="fas fa-credit-card me-1"></i>Annual Price ID:
<code class="text-primary">{{ plan.stripe_annual_price_id }}</code>
</small>
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="mb-3"> <div class="mb-3">
<strong>Features:</strong> <strong>Features:</strong>
<ul class="list-unstyled mt-2"> <ul class="list-unstyled mt-2">
@@ -62,10 +95,6 @@
</ul> </ul>
</div> </div>
<div class="mb-3">
<strong>Button:</strong> {{ plan.button_text }} → {{ plan.button_url }}
</div>
<div class="mb-3"> <div class="mb-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input plan-popular-toggle" type="checkbox" <input class="form-check-input plan-popular-toggle" type="checkbox"
@@ -220,11 +249,29 @@
value="Get Started"> value="Get Started">
</div> </div>
</div> </div>
<div class="col-md-6"> </div>
<!-- Stripe Product/Price IDs Section -->
<div class="row">
<div class="col-md-4">
<div class="mb-3"> <div class="mb-3">
<label for="buttonUrl" class="form-label">Button URL</label> <label for="stripeProductId" class="form-label">Stripe Product ID</label>
<input type="text" class="form-control" id="buttonUrl" name="button_url" <input type="text" class="form-control" id="stripeProductId" name="stripe_product_id" placeholder="prod_xxx">
value="#"> <small class="text-muted">The Stripe Product ID for this plan</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="stripeMonthlyPriceId" class="form-label">Stripe Monthly Price ID</label>
<input type="text" class="form-control" id="stripeMonthlyPriceId" name="stripe_monthly_price_id" placeholder="price_xxx">
<small class="text-muted">The Stripe Price ID for monthly billing</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="stripeAnnualPriceId" class="form-label">Stripe Annual Price ID</label>
<input type="text" class="form-control" id="stripeAnnualPriceId" name="stripe_annual_price_id" placeholder="price_xxx">
<small class="text-muted">The Stripe Price ID for annual billing</small>
</div> </div>
</div> </div>
</div> </div>
@@ -367,10 +414,29 @@
<input type="text" class="form-control" id="editButtonText" name="button_text"> <input type="text" class="form-control" id="editButtonText" name="button_text">
</div> </div>
</div> </div>
<div class="col-md-6"> </div>
<!-- Stripe Product/Price IDs Section -->
<div class="row">
<div class="col-md-4">
<div class="mb-3"> <div class="mb-3">
<label for="editButtonUrl" class="form-label">Button URL</label> <label for="stripeProductId" class="form-label">Stripe Product ID</label>
<input type="text" class="form-control" id="editButtonUrl" name="button_url"> <input type="text" class="form-control" id="stripeProductId" name="stripe_product_id" placeholder="prod_xxx">
<small class="text-muted">The Stripe Product ID for this plan</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="stripeMonthlyPriceId" class="form-label">Stripe Monthly Price ID</label>
<input type="text" class="form-control" id="stripeMonthlyPriceId" name="stripe_monthly_price_id" placeholder="price_xxx">
<small class="text-muted">The Stripe Price ID for monthly billing</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="stripeAnnualPriceId" class="form-label">Stripe Annual Price ID</label>
<input type="text" class="form-control" id="stripeAnnualPriceId" name="stripe_annual_price_id" placeholder="price_xxx">
<small class="text-muted">The Stripe Price ID for annual billing</small>
</div> </div>
</div> </div>
</div> </div>

53
timespent.py Normal file
View File

@@ -0,0 +1,53 @@
import subprocess
from datetime import datetime, timedelta
# Run git log command
log_output = subprocess.check_output(
['git', 'log', '--pretty=format:%h %an %ad', '--date=iso'],
text=True
)
# Parse commit dates
commit_times = []
for line in log_output.splitlines():
parts = line.strip().split()
if len(parts) < 4:
continue
# Commit hash, author, datetime string
dt_str = " ".join(parts[2:4]) # "YYYY-MM-DD HH:MM:SS"
try:
dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S")
commit_times.append(dt)
except ValueError:
continue
# Sort commits chronologically
commit_times.sort()
# Session grouping (commits < 1 hour apart are same session)
SESSION_GAP = timedelta(hours=1)
sessions = []
if commit_times:
start = commit_times[0]
prev = commit_times[0]
for t in commit_times[1:]:
if t - prev > SESSION_GAP:
# Close previous session
sessions.append((start, prev))
start = t
prev = t
sessions.append((start, prev)) # last session
# Estimate durations
total_time = timedelta()
for start, end in sessions:
duration = end - start
# Add a minimum session length (e.g. 30 min) so single commits arent near-zero
if duration < timedelta(minutes=30):
duration = timedelta(minutes=30)
total_time += duration
print(f"Number of commits: {len(commit_times)}")
print(f"Number of sessions: {len(sessions)}")
print(f"Estimated total coding time: {total_time} (~{total_time.total_seconds()/3600:.1f} hours)")

444
utils/stripe_utils.py Normal file
View File

@@ -0,0 +1,444 @@
"""
Stripe utility functions for managing products, prices, and checkout sessions.
"""
import stripe
import os
from models import KeyValueSettings, PricingPlan
from flask import current_app, url_for
import logging
logger = logging.getLogger(__name__)
def get_stripe_settings():
"""Get Stripe settings from database"""
stripe_settings = KeyValueSettings.get_value('stripe_settings')
if not stripe_settings:
return None
return stripe_settings
def configure_stripe():
"""Configure Stripe with API key from settings"""
stripe_settings = get_stripe_settings()
if not stripe_settings or not stripe_settings.get('secret_key'):
raise ValueError("Stripe secret key not configured")
stripe.api_key = stripe_settings['secret_key']
return stripe_settings
def create_stripe_product(plan):
"""
Create a Stripe product and prices for a pricing plan
Args:
plan: PricingPlan instance
Returns:
dict: Contains product_id, monthly_price_id, annual_price_id
"""
try:
configure_stripe()
# Create product
product = stripe.Product.create(
name=plan.name,
description=plan.description or f"DocuPulse {plan.name} Plan",
metadata={
'plan_id': plan.id,
'plan_name': plan.name
}
)
# Create monthly price
monthly_price = stripe.Price.create(
product=product.id,
unit_amount=int(plan.monthly_price * 100), # Convert to cents
currency='eur',
recurring={'interval': 'month'},
metadata={
'plan_id': plan.id,
'billing_cycle': 'monthly',
'plan_name': plan.name
}
)
# Create annual price
annual_price = stripe.Price.create(
product=product.id,
unit_amount=int(plan.annual_price * 100), # Convert to cents
currency='eur',
recurring={'interval': 'year'},
metadata={
'plan_id': plan.id,
'billing_cycle': 'annual',
'plan_name': plan.name
}
)
return {
'product_id': product.id,
'monthly_price_id': monthly_price.id,
'annual_price_id': annual_price.id
}
except stripe.error.StripeError as e:
logger.error(f"Stripe error creating product for plan {plan.name}: {str(e)}")
raise
except Exception as e:
logger.error(f"Error creating Stripe product for plan {plan.name}: {str(e)}")
raise
def update_stripe_product(plan):
"""
Update an existing Stripe product and prices for a pricing plan
Args:
plan: PricingPlan instance with existing Stripe IDs
Returns:
dict: Updated product and price information
"""
try:
configure_stripe()
if not plan.stripe_product_id:
# If no product ID exists, create new product
return create_stripe_product(plan)
# Update product
product = stripe.Product.modify(
plan.stripe_product_id,
name=plan.name,
description=plan.description or f"DocuPulse {plan.name} Plan",
metadata={
'plan_id': plan.id,
'plan_name': plan.name
}
)
# Archive old prices and create new ones
new_prices = {}
# Handle monthly price
if plan.stripe_monthly_price_id:
try:
# Archive old monthly price
stripe.Price.modify(plan.stripe_monthly_price_id, active=False)
except stripe.error.StripeError:
pass # Price might not exist
# Create new monthly price
monthly_price = stripe.Price.create(
product=product.id,
unit_amount=int(plan.monthly_price * 100),
currency='eur',
recurring={'interval': 'month'},
metadata={
'plan_id': plan.id,
'billing_cycle': 'monthly',
'plan_name': plan.name
}
)
new_prices['monthly_price_id'] = monthly_price.id
# Handle annual price
if plan.stripe_annual_price_id:
try:
# Archive old annual price
stripe.Price.modify(plan.stripe_annual_price_id, active=False)
except stripe.error.StripeError:
pass # Price might not exist
# Create new annual price
annual_price = stripe.Price.create(
product=product.id,
unit_amount=int(plan.annual_price * 100),
currency='eur',
recurring={'interval': 'year'},
metadata={
'plan_id': plan.id,
'billing_cycle': 'annual',
'plan_name': plan.name
}
)
new_prices['annual_price_id'] = annual_price.id
return {
'product_id': product.id,
'monthly_price_id': new_prices['monthly_price_id'],
'annual_price_id': new_prices['annual_price_id']
}
except stripe.error.StripeError as e:
logger.error(f"Stripe error updating product for plan {plan.name}: {str(e)}")
raise
except Exception as e:
logger.error(f"Error updating Stripe product for plan {plan.name}: {str(e)}")
raise
def create_checkout_session(plan_id, billing_cycle='monthly', success_url=None, cancel_url=None):
"""
Create a Stripe checkout session for a pricing plan
Args:
plan_id: ID of the PricingPlan
billing_cycle: 'monthly' or 'annual'
success_url: URL to redirect to on successful payment
cancel_url: URL to redirect to on cancellation
Returns:
str: Checkout session URL
"""
logger.info(f"=== CREATE CHECKOUT SESSION START ===")
logger.info(f"Plan ID: {plan_id}")
logger.info(f"Billing cycle: {billing_cycle}")
logger.info(f"Success URL: {success_url}")
logger.info(f"Cancel URL: {cancel_url}")
try:
configure_stripe()
logger.info("Stripe configured successfully")
plan = PricingPlan.query.get(plan_id)
logger.info(f"Plan lookup result: {plan}")
if not plan:
logger.error(f"Pricing plan with ID {plan_id} not found")
raise ValueError(f"Pricing plan with ID {plan_id} not found")
logger.info(f"Plan found: {plan.name}")
logger.info(f"Plan stripe_monthly_price_id: {plan.stripe_monthly_price_id}")
logger.info(f"Plan stripe_annual_price_id: {plan.stripe_annual_price_id}")
# Determine which price ID to use
if billing_cycle == 'monthly':
price_id = plan.stripe_monthly_price_id
if not price_id:
logger.error("Monthly price not configured for this plan")
raise ValueError("Monthly price not configured for this plan")
elif billing_cycle == 'annual':
price_id = plan.stripe_annual_price_id
if not price_id:
logger.error("Annual price not configured for this plan")
raise ValueError("Annual price not configured for this plan")
else:
logger.error(f"Invalid billing cycle: {billing_cycle}")
raise ValueError("Invalid billing cycle. Must be 'monthly' or 'annual'")
logger.info(f"Using price ID: {price_id}")
# Set default URLs if not provided
if not success_url:
success_url = url_for('main.dashboard', _external=True)
if not cancel_url:
cancel_url = url_for('main.public_home', _external=True)
logger.info(f"Final success URL: {success_url}")
logger.info(f"Final cancel URL: {cancel_url}")
# Create checkout session
session_data = {
'payment_method_types': ['card'],
'line_items': [{
'price': price_id,
'quantity': 1,
}],
'mode': 'subscription',
'success_url': f"{success_url}?session_id={{CHECKOUT_SESSION_ID}}",
'cancel_url': cancel_url,
'metadata': {
'plan_id': plan_id,
'plan_name': plan.name,
'billing_cycle': billing_cycle
},
'customer_email': None, # Will be collected during checkout
'allow_promotion_codes': True,
'billing_address_collection': 'required',
'phone_number_collection': {
'enabled': True
},
'automatic_tax': {
'enabled': True
},
'tax_id_collection': {
'enabled': True
}
}
logger.info(f"Creating Stripe session with data: {session_data}")
session = stripe.checkout.Session.create(**session_data)
logger.info(f"Stripe session created successfully: {session.id}")
logger.info(f"Session URL: {session.url}")
return session.url
except stripe.error.StripeError as e:
logger.error(f"Stripe error creating checkout session: {str(e)}")
raise
except Exception as e:
logger.error(f"Error creating checkout session: {str(e)}")
raise
finally:
logger.info("=== CREATE CHECKOUT SESSION END ===")
def get_subscription_info(session_id):
"""
Get subscription information from a checkout session
Args:
session_id: Stripe checkout session ID
Returns:
dict: Subscription information
"""
try:
configure_stripe()
session = stripe.checkout.Session.retrieve(session_id)
if session.payment_status == 'paid':
subscription = stripe.Subscription.retrieve(session.subscription)
# Get customer details
customer_details = {}
if session.customer_details:
customer_details = {
'name': session.customer_details.name,
'email': session.customer_details.email,
'phone': session.customer_details.phone,
'address': {
'line1': session.customer_details.address.line1,
'line2': session.customer_details.address.line2,
'city': session.customer_details.address.city,
'state': session.customer_details.address.state,
'postal_code': session.customer_details.address.postal_code,
'country': session.customer_details.address.country
} if session.customer_details.address else None,
'shipping': {
'name': session.customer_details.shipping.name,
'address': {
'line1': session.customer_details.shipping.address.line1,
'line2': session.customer_details.shipping.address.line2,
'city': session.customer_details.shipping.address.city,
'state': session.customer_details.shipping.address.state,
'postal_code': session.customer_details.shipping.address.postal_code,
'country': session.customer_details.shipping.address.country
}
} if session.customer_details.shipping else None,
'tax_ids': [
{
'type': tax_id.type,
'value': tax_id.value
} for tax_id in session.customer_details.tax_ids
] if session.customer_details.tax_ids else []
}
return {
'session_id': session_id,
'subscription_id': subscription.id,
'customer_id': subscription.customer,
'status': subscription.status,
'plan_id': session.metadata.get('plan_id'),
'plan_name': session.metadata.get('plan_name'),
'billing_cycle': session.metadata.get('billing_cycle'),
'current_period_start': subscription.current_period_start,
'current_period_end': subscription.current_period_end,
'amount': subscription.items.data[0].price.unit_amount / 100, # Convert from cents
'currency': subscription.currency,
'customer_details': customer_details
}
else:
return {
'session_id': session_id,
'payment_status': session.payment_status,
'error': 'Payment not completed'
}
except stripe.error.StripeError as e:
logger.error(f"Stripe error getting subscription info: {str(e)}")
raise
except Exception as e:
logger.error(f"Error getting subscription info: {str(e)}")
raise
def cancel_subscription(subscription_id):
"""
Cancel a Stripe subscription
Args:
subscription_id: Stripe subscription ID
Returns:
dict: Cancellation information
"""
try:
configure_stripe()
subscription = stripe.Subscription.modify(
subscription_id,
cancel_at_period_end=True
)
return {
'subscription_id': subscription_id,
'status': subscription.status,
'cancel_at_period_end': subscription.cancel_at_period_end,
'current_period_end': subscription.current_period_end
}
except stripe.error.StripeError as e:
logger.error(f"Stripe error canceling subscription: {str(e)}")
raise
except Exception as e:
logger.error(f"Error canceling subscription: {str(e)}")
raise
def validate_stripe_keys():
"""
Validate that Stripe keys are properly configured
Returns:
dict: Validation result with status and message
"""
try:
stripe_settings = get_stripe_settings()
if not stripe_settings:
return {
'valid': False,
'message': 'Stripe settings not configured'
}
if not stripe_settings.get('secret_key'):
return {
'valid': False,
'message': 'Stripe secret key not configured'
}
if not stripe_settings.get('publishable_key'):
return {
'valid': False,
'message': 'Stripe publishable key not configured'
}
# Test the API key
configure_stripe()
account = stripe.Account.retrieve()
return {
'valid': True,
'message': 'Stripe configuration is valid',
'account_id': account.id,
'test_mode': stripe_settings.get('test_mode', False)
}
except stripe.error.AuthenticationError:
return {
'valid': False,
'message': 'Invalid Stripe API key'
}
except Exception as e:
return {
'valid': False,
'message': f'Error validating Stripe configuration: {str(e)}'
}