Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0d436d116 | |||
| 36da0717a2 | |||
| 8a622334d0 | |||
| b1da4977d3 | |||
| 9b85f3bb8d | |||
| 3a0659b63b | |||
| 5b598f2966 | |||
| 77032062a1 | |||
| 81675af837 | |||
| 0a2cddf122 | |||
| 56d94a06ce | |||
| de3880e880 | |||
| 0466b11c71 | |||
| e519dc3a8b | |||
| ac9f002365 | |||
| 8de74827f2 | |||
| 81552bc5ec | |||
| 490bc05a9e | |||
| cc699506d3 |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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!")
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -25,6 +25,13 @@ services:
|
||||
- GIT_COMMIT=${GIT_COMMIT:-unknown}
|
||||
- GIT_BRANCH=${GIT_BRANCH:-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:
|
||||
- docupulse_uploads:/app/uploads
|
||||
depends_on:
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
57
migrations/versions/add_customer_table.py
Normal file
57
migrations/versions/add_customer_table.py
Normal 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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
42
models.py
42
models.py
@@ -603,6 +603,13 @@ class PricingPlan(db.Model):
|
||||
is_custom = db.Column(db.Boolean, default=False)
|
||||
button_text = db.Column(db.String(50), default='Get Started')
|
||||
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)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
# Quota fields
|
||||
@@ -678,3 +685,38 @@ class PricingPlan(db.Model):
|
||||
elif quota_type == 'admin_quota':
|
||||
return float('inf') if self.admin_quota == 0 else max(0, self.admin_quota - current_count)
|
||||
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}>'
|
||||
@@ -14,3 +14,4 @@ requests>=2.31.0
|
||||
gunicorn==21.2.0
|
||||
prometheus-client>=0.16.0
|
||||
PyJWT>=2.8.0
|
||||
stripe>=7.0.0
|
||||
Binary file not shown.
Binary file not shown.
118
routes/admin.py
118
routes/admin.py
@@ -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 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
|
||||
from datetime import datetime
|
||||
import json
|
||||
from routes.auth import require_password_change
|
||||
|
||||
admin = Blueprint('admin', __name__)
|
||||
|
||||
@@ -258,6 +261,7 @@ def get_usage_stats():
|
||||
|
||||
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['PUT'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def update_help_article(article_id):
|
||||
"""Update a help article"""
|
||||
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'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def delete_help_article(article_id):
|
||||
"""Delete a help article"""
|
||||
if not current_user.is_admin:
|
||||
@@ -342,6 +347,7 @@ def delete_help_article(article_id):
|
||||
# Help Articles API endpoints
|
||||
@admin.route('/api/admin/help-articles', methods=['GET'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def get_help_articles():
|
||||
"""Get all help articles"""
|
||||
if not current_user.is_admin:
|
||||
@@ -367,6 +373,7 @@ def get_help_articles():
|
||||
|
||||
@admin.route('/api/admin/help-articles', methods=['POST'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def create_help_article():
|
||||
"""Create a new help article"""
|
||||
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'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def get_help_article(article_id):
|
||||
"""Get a specific help article"""
|
||||
if not current_user.is_admin:
|
||||
@@ -454,6 +462,7 @@ def create_pricing_plan():
|
||||
|
||||
try:
|
||||
from models import PricingPlan
|
||||
from utils.stripe_utils import create_stripe_product
|
||||
|
||||
# Get form data
|
||||
name = request.form.get('name')
|
||||
@@ -462,11 +471,15 @@ def create_pricing_plan():
|
||||
annual_price = float(request.form.get('annual_price'))
|
||||
features = json.loads(request.form.get('features', '[]'))
|
||||
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_custom = request.form.get('is_custom') == '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
|
||||
room_quota = int(request.form.get('room_quota', 0))
|
||||
conversation_quota = int(request.form.get('conversation_quota', 0))
|
||||
@@ -489,7 +502,9 @@ def create_pricing_plan():
|
||||
annual_price=annual_price,
|
||||
features=features,
|
||||
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_custom=is_custom,
|
||||
is_active=is_active,
|
||||
@@ -505,6 +520,18 @@ def create_pricing_plan():
|
||||
db.session.add(plan)
|
||||
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'})
|
||||
|
||||
except Exception as e:
|
||||
@@ -535,6 +562,9 @@ def get_pricing_plan(plan_id):
|
||||
'features': plan.features,
|
||||
'button_text': plan.button_text,
|
||||
'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_custom': plan.is_custom,
|
||||
'is_active': plan.is_active,
|
||||
@@ -563,6 +593,7 @@ def update_pricing_plan(plan_id):
|
||||
|
||||
try:
|
||||
from models import PricingPlan
|
||||
from utils.stripe_utils import update_stripe_product
|
||||
|
||||
plan = PricingPlan.query.get(plan_id)
|
||||
if not plan:
|
||||
@@ -575,11 +606,15 @@ def update_pricing_plan(plan_id):
|
||||
annual_price = float(request.form.get('annual_price'))
|
||||
features = json.loads(request.form.get('features', '[]'))
|
||||
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_custom = request.form.get('is_custom') == '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
|
||||
room_quota = int(request.form.get('room_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.features = features
|
||||
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_custom = is_custom
|
||||
plan.is_active = is_active
|
||||
@@ -610,6 +647,18 @@ def update_pricing_plan(plan_id):
|
||||
|
||||
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'})
|
||||
|
||||
except Exception as e:
|
||||
@@ -726,3 +775,60 @@ def get_pricing_plans():
|
||||
|
||||
except Exception as e:
|
||||
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
|
||||
@@ -929,8 +929,8 @@ def deploy_stack():
|
||||
def check_stack_status():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'stack_name' not in data:
|
||||
return jsonify({'error': 'Missing stack_name field'}), 400
|
||||
if not data or ('stack_name' not in data and 'stack_id' not in data):
|
||||
return jsonify({'error': 'Missing stack_name or stack_id field'}), 400
|
||||
|
||||
# Get Portainer settings
|
||||
portainer_settings = KeyValueSettings.get_value('portainer_settings')
|
||||
@@ -956,7 +956,26 @@ def check_stack_status():
|
||||
|
||||
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_response = requests.get(
|
||||
stacks_url,
|
||||
@@ -984,7 +1003,7 @@ def check_stack_status():
|
||||
|
||||
# Get stack services to check their status
|
||||
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:
|
||||
services_response = requests.get(
|
||||
@@ -993,7 +1012,7 @@ def check_stack_status():
|
||||
'X-API-Key': portainer_settings['api_key'],
|
||||
'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
|
||||
)
|
||||
|
||||
@@ -1001,46 +1020,40 @@ def check_stack_status():
|
||||
|
||||
if services_response.ok:
|
||||
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 = []
|
||||
|
||||
for service in services:
|
||||
replicas_running = service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0)
|
||||
replicas_actual = service.get('ServiceStatus', {}).get('RunningTasks', 0)
|
||||
|
||||
service_status = {
|
||||
service_statuses.append({
|
||||
'name': service.get('Spec', {}).get('Name', 'Unknown'),
|
||||
'replicas_expected': replicas_running,
|
||||
'replicas_running': replicas_actual,
|
||||
'status': 'running' if replicas_actual >= replicas_running else 'not_running'
|
||||
}
|
||||
|
||||
service_statuses.append(service_status)
|
||||
|
||||
if replicas_actual < replicas_running:
|
||||
all_running = False
|
||||
'replicas': service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0),
|
||||
'running_replicas': service.get('ServiceStatus', {}).get('RunningTasks', 0),
|
||||
'desired_replicas': service.get('ServiceStatus', {}).get('DesiredTasks', 0)
|
||||
})
|
||||
|
||||
# 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'
|
||||
elif len(services) > 0:
|
||||
elif any_running:
|
||||
status = 'partial'
|
||||
else:
|
||||
status = 'inactive'
|
||||
else:
|
||||
# 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
|
||||
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:
|
||||
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:
|
||||
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 = []
|
||||
service_statuses = []
|
||||
@@ -1048,7 +1061,7 @@ def check_stack_status():
|
||||
|
||||
except Exception as e:
|
||||
# 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 = []
|
||||
service_statuses = []
|
||||
status = 'starting' # Stack exists but services not available yet
|
||||
@@ -1056,14 +1069,10 @@ def check_stack_status():
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'stack_name': data['stack_name'],
|
||||
'name': target_stack['Name'],
|
||||
'stack_id': target_stack['Id'],
|
||||
'status': status,
|
||||
'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')
|
||||
'services': service_statuses
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1855,3 +1864,215 @@ def copy_smtp_settings():
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error copying SMTP settings: {str(e)}")
|
||||
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
|
||||
515
routes/main.py
515
routes/main.py
@@ -1,7 +1,8 @@
|
||||
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 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 extensions import csrf
|
||||
import os
|
||||
from werkzeug.utils import secure_filename
|
||||
from sqlalchemy import func, case, literal_column, text
|
||||
@@ -19,6 +20,8 @@ import smtplib
|
||||
import requests
|
||||
from functools import wraps
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
import stripe
|
||||
|
||||
# Set up logging to show in console
|
||||
logging.basicConfig(
|
||||
@@ -491,13 +494,197 @@ def init_routes(main_bp):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
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:
|
||||
db.session.delete(instance)
|
||||
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:
|
||||
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')
|
||||
@login_required
|
||||
@@ -589,6 +776,32 @@ def init_routes(main_bp):
|
||||
|
||||
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')
|
||||
@login_required
|
||||
@require_password_change
|
||||
@@ -1139,6 +1352,7 @@ def init_routes(main_bp):
|
||||
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
||||
git_settings = KeyValueSettings.get_value('git_settings')
|
||||
cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings')
|
||||
stripe_settings = KeyValueSettings.get_value('stripe_settings')
|
||||
|
||||
# Get management API key for the connections tab
|
||||
management_api_key = ManagementAPIKey.query.filter_by(is_active=True).first()
|
||||
@@ -1216,6 +1430,7 @@ def init_routes(main_bp):
|
||||
nginx_settings=nginx_settings,
|
||||
git_settings=git_settings,
|
||||
cloudflare_settings=cloudflare_settings,
|
||||
stripe_settings=stripe_settings,
|
||||
pricing_plans=pricing_plans,
|
||||
csrf_token=generate_csrf())
|
||||
|
||||
@@ -1914,13 +2129,12 @@ def init_routes(main_bp):
|
||||
email = data.get('email')
|
||||
api_key = data.get('api_key')
|
||||
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
|
||||
|
||||
try:
|
||||
# Test Cloudflare connection by getting zone details
|
||||
# Test Cloudflare connection
|
||||
headers = {
|
||||
'X-Auth-Email': email,
|
||||
'X-Auth-Key': api_key,
|
||||
@@ -1931,21 +2145,77 @@ def init_routes(main_bp):
|
||||
response = requests.get(
|
||||
f'https://api.cloudflare.com/client/v4/zones/{zone_id}',
|
||||
headers=headers,
|
||||
timeout=10
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
zone_data = response.json()
|
||||
if zone_data.get('success'):
|
||||
return jsonify({'message': 'Connection successful'})
|
||||
else:
|
||||
return jsonify({'error': f'API error: {zone_data.get("errors", [{}])[0].get("message", "Unknown error")}'}), 400
|
||||
else:
|
||||
return jsonify({'error': f'Connection failed: HTTP {response.status_code}'}), 400
|
||||
return jsonify({'error': f'Connection failed: {response.json().get("errors", [{}])[0].get("message", "Unknown error")}'}), 400
|
||||
|
||||
except Exception as e:
|
||||
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')
|
||||
@login_required
|
||||
@require_password_change
|
||||
@@ -1954,6 +2224,12 @@ def init_routes(main_bp):
|
||||
flash('This page is only available in master instances.', 'error')
|
||||
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
|
||||
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
||||
# Get Portainer settings
|
||||
@@ -1964,7 +2240,11 @@ def init_routes(main_bp):
|
||||
return render_template('main/launch_progress.html',
|
||||
nginx_settings=nginx_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'])
|
||||
@login_required
|
||||
@@ -2046,6 +2326,13 @@ def init_routes(main_bp):
|
||||
@login_required
|
||||
@require_password_change
|
||||
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':
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
@@ -2092,7 +2379,7 @@ def init_routes(main_bp):
|
||||
'name': domain,
|
||||
'content': cloudflare_settings['server_ip'],
|
||||
'ttl': 1, # Auto TTL
|
||||
'proxied': True
|
||||
'proxied': False # DNS only - no Cloudflare proxy to avoid conflicts with NGINX
|
||||
}
|
||||
|
||||
update_response = requests.put(
|
||||
@@ -2113,7 +2400,7 @@ def init_routes(main_bp):
|
||||
'name': domain,
|
||||
'content': cloudflare_settings['server_ip'],
|
||||
'ttl': 1, # Auto TTL
|
||||
'proxied': True
|
||||
'proxied': False # DNS only - no Cloudflare proxy to avoid conflicts with NGINX
|
||||
}
|
||||
|
||||
create_response = requests.post(
|
||||
@@ -2192,3 +2479,201 @@ def init_routes(main_bp):
|
||||
'branch': branch,
|
||||
'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)
|
||||
@@ -57,7 +57,12 @@ def init_public_routes(public_bp):
|
||||
articles = HelpArticle.get_articles_by_category(category)
|
||||
category_name = categories[category]
|
||||
else:
|
||||
# Show all articles when no specific category is requested
|
||||
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
|
||||
|
||||
return render_template('public/help_articles.html',
|
||||
|
||||
263
static/css/instances.css
Normal file
263
static/css/instances.css
Normal 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
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
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
@@ -529,6 +529,95 @@ async function saveCloudflareConnection(event) {
|
||||
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
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const gitSettings = JSON.parse(document.querySelector('meta[name="git-settings"]').getAttribute('content'));
|
||||
|
||||
@@ -164,7 +164,9 @@ function loadPlanForEdit(planId) {
|
||||
document.getElementById('editMonthlyPrice').value = plan.monthly_price;
|
||||
document.getElementById('editAnnualPrice').value = plan.annual_price;
|
||||
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('editIsCustom').checked = plan.is_custom;
|
||||
document.getElementById('editIsActive').checked = plan.is_active;
|
||||
|
||||
149
templates/admin/customer_details_modal.html
Normal file
149
templates/admin/customer_details_modal.html
Normal 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 %}
|
||||
178
templates/admin/customers.html
Normal file
178
templates/admin/customers.html
Normal 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 %}
|
||||
@@ -1,7 +1,12 @@
|
||||
{% extends "common/base.html" %}
|
||||
{% from "components/header.html" import header %}
|
||||
|
||||
{% block title %}Support Articles - DocuPulse{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
|
||||
<style>
|
||||
@@ -65,18 +70,24 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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="row">
|
||||
<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 -->
|
||||
<div class="row" id="articlesList">
|
||||
<!-- Articles will be loaded here via AJAX -->
|
||||
@@ -227,6 +238,7 @@
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete this article? This action cannot be undone.</p>
|
||||
<p class="text-muted" id="deleteArticleTitle"></p>
|
||||
<input type="hidden" id="deleteArticleId" value="">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
@@ -295,7 +307,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
function loadArticles() {
|
||||
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 => {
|
||||
const articlesList = document.getElementById('articlesList');
|
||||
articlesList.innerHTML = '';
|
||||
@@ -377,7 +397,17 @@ function createArticle() {
|
||||
method: 'POST',
|
||||
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 => {
|
||||
if (data.success) {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createArticleModal'));
|
||||
@@ -398,7 +428,15 @@ function createArticle() {
|
||||
|
||||
function editArticle(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 => {
|
||||
document.getElementById('editArticleId').value = data.article.id;
|
||||
document.getElementById('editArticleTitle').value = data.article.title;
|
||||
@@ -426,7 +464,15 @@ function updateArticle() {
|
||||
method: 'PUT',
|
||||
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 => {
|
||||
if (data.success) {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('editArticleModal'));
|
||||
@@ -457,7 +503,15 @@ function deleteArticle() {
|
||||
fetch(`/api/admin/help-articles/${articleId}`, {
|
||||
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 => {
|
||||
if (data.success) {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteArticleModal'));
|
||||
|
||||
290
templates/checkout_success.html
Normal file
290
templates/checkout_success.html
Normal 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>
|
||||
@@ -63,11 +63,9 @@
|
||||
<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>
|
||||
{% else %}
|
||||
{% if not is_master %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('main.profile') }}"><i class="fas fa-user"></i> Profile</a></li>
|
||||
{% if current_user.is_admin %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -105,6 +103,11 @@
|
||||
<i class="fas fa-life-ring"></i> Support Articles
|
||||
</a>
|
||||
</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 %}
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</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 %}
|
||||
<li class="nav-item d-lg-none">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
|
||||
{% set pricing_plans = PricingPlan.get_active_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">
|
||||
{% for plan in pricing_plans %}
|
||||
<div class="col-md-3">
|
||||
@@ -47,9 +60,28 @@
|
||||
</ul>
|
||||
</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 }}
|
||||
</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>
|
||||
@@ -80,7 +112,7 @@
|
||||
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
||||
<span class="monthly-price">€29</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>
|
||||
<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>
|
||||
@@ -107,7 +139,7 @@
|
||||
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
||||
<span class="monthly-price">€99</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>
|
||||
<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>
|
||||
@@ -129,7 +161,7 @@
|
||||
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
||||
<span class="monthly-price">€299</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>
|
||||
<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>
|
||||
@@ -179,11 +211,31 @@
|
||||
|
||||
<script>
|
||||
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');
|
||||
if (!billingToggle) return;
|
||||
|
||||
const monthlyPrices = document.querySelectorAll('.monthly-price');
|
||||
const annualPrices = document.querySelectorAll('.annual-price');
|
||||
const pricePeriods = document.querySelectorAll('.price-period');
|
||||
|
||||
// Add CSS for switch styling
|
||||
const style = document.createElement('style');
|
||||
@@ -229,6 +281,89 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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() {
|
||||
if (this.checked) {
|
||||
// Switch to annual prices with animation
|
||||
@@ -242,6 +377,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Simply animate the number change
|
||||
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 {
|
||||
// Switch to monthly prices with animation
|
||||
monthlyPrices.forEach((price, index) => {
|
||||
@@ -251,8 +393,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Simply animate the number change back to monthly
|
||||
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>
|
||||
@@ -8,6 +8,7 @@
|
||||
<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() }}')">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/home.css') }}?v={{ 'css/home.css'|asset_version }}">
|
||||
<style>
|
||||
.admin-link {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,9 @@
|
||||
|
||||
{% block content %}
|
||||
{{ header(
|
||||
title="Launching Instance",
|
||||
description="Setting up your new DocuPulse instance",
|
||||
icon="fa-rocket"
|
||||
title=is_update and "Updating Instance" or "Launching Instance",
|
||||
description=is_update and "Updating your DocuPulse instance with the latest version" or "Setting up your new DocuPulse instance",
|
||||
icon="fa-arrow-up" if is_update else "fa-rocket"
|
||||
) }}
|
||||
|
||||
<div class="container-fluid">
|
||||
@@ -78,6 +78,12 @@
|
||||
|
||||
// Pass CSRF token to JavaScript
|
||||
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 src="{{ url_for('static', filename='js/launch_progress.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.about-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.careers-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.legal-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.contact-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
/* Enhanced Features Page Styles */
|
||||
.hero-section {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.legal-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.help-section {
|
||||
padding: 80px 0;
|
||||
@@ -415,23 +416,5 @@
|
||||
{% include 'components/footer_nav.html' %}
|
||||
|
||||
<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>
|
||||
</html>
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.category-nav {
|
||||
background: var(--white);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.press-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.pricing-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.legal-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.security-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
.legal-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
{% if is_master %}
|
||||
<!-- 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>
|
||||
|
||||
<!-- Pricing Tab -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% 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 name="management-api-key" content="{{ site_settings.management_api_key }}">
|
||||
<meta name="git-settings" content="{{ git_settings|tojson|safe }}">
|
||||
@@ -212,6 +212,67 @@
|
||||
</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>
|
||||
|
||||
<!-- Save Connection Modal -->
|
||||
|
||||
@@ -53,6 +53,39 @@
|
||||
</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">
|
||||
<strong>Features:</strong>
|
||||
<ul class="list-unstyled mt-2">
|
||||
@@ -62,10 +95,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Button:</strong> {{ plan.button_text }} → {{ plan.button_url }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input plan-popular-toggle" type="checkbox"
|
||||
@@ -220,11 +249,29 @@
|
||||
value="Get Started">
|
||||
</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">
|
||||
<label for="buttonUrl" class="form-label">Button URL</label>
|
||||
<input type="text" class="form-control" id="buttonUrl" name="button_url"
|
||||
value="#">
|
||||
<label for="stripeProductId" class="form-label">Stripe Product ID</label>
|
||||
<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>
|
||||
@@ -367,10 +414,29 @@
|
||||
<input type="text" class="form-control" id="editButtonText" name="button_text">
|
||||
</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">
|
||||
<label for="editButtonUrl" class="form-label">Button URL</label>
|
||||
<input type="text" class="form-control" id="editButtonUrl" name="button_url">
|
||||
<label for="stripeProductId" class="form-label">Stripe Product ID</label>
|
||||
<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>
|
||||
|
||||
53
timespent.py
Normal file
53
timespent.py
Normal 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 aren’t 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)")
|
||||
Binary file not shown.
444
utils/stripe_utils.py
Normal file
444
utils/stripe_utils.py
Normal 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)}'
|
||||
}
|
||||
Reference in New Issue
Block a user