From 996adb4bce96d3f9d2ad2bb6ae4ce2d3daace6f2 Mon Sep 17 00:00:00 2001 From: Kobe Date: Tue, 24 Jun 2025 11:25:10 +0200 Subject: [PATCH] pricing --- PRICING_CONFIGURATION.md | 246 ++++++++++ __pycache__/models.cpython-313.pyc | Bin 42981 -> 49477 bytes init_pricing_plans.py | 170 +++++++ .../versions/add_pricing_plans_table.py | 62 +++ .../add_quota_fields_to_pricing_plans.py | 55 +++ models.py | 90 +++- routes/__pycache__/admin.cpython-313.pyc | Bin 20285 -> 32398 bytes routes/__pycache__/main.cpython-313.pyc | Bin 109501 -> 109782 bytes routes/admin.py | 239 +++++++++- routes/main.py | 9 +- routes/public.py | 7 +- static/js/settings/pricing.js | 310 +++++++++++++ templates/components/pricing_section.html | 70 +++ templates/settings/settings.html | 14 + templates/settings/tabs/pricing.html | 427 ++++++++++++++++++ 15 files changed, 1695 insertions(+), 4 deletions(-) create mode 100644 PRICING_CONFIGURATION.md create mode 100644 init_pricing_plans.py create mode 100644 migrations/versions/add_pricing_plans_table.py create mode 100644 migrations/versions/add_quota_fields_to_pricing_plans.py create mode 100644 static/js/settings/pricing.js create mode 100644 templates/settings/tabs/pricing.html diff --git a/PRICING_CONFIGURATION.md b/PRICING_CONFIGURATION.md new file mode 100644 index 0000000..2ff76a5 --- /dev/null +++ b/PRICING_CONFIGURATION.md @@ -0,0 +1,246 @@ +# 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/` - Get plan details +- `PUT /api/admin/pricing-plans/` - Update plan +- `DELETE /api/admin/pricing-plans/` - Delete plan +- `PATCH /api/admin/pricing-plans//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 \ No newline at end of file diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc index 70d76758f7b7bba8c8d8fa6eef30990b2888ecea..5ad7012ddfeafe142a8183b005593d3b98833ece 100644 GIT binary patch delta 4452 zcmb7HYfKy26`rv%7;H0`F_`Bv*xm+1fbc3Y;SrLMEG!!y1C)>~u450x1luqkb^}di zZ=|lY4=IG(7RYV|iCTrNRLK!P%Bow1NNJmYJ0q(y8dX*HM_Z}wcGK+ck5>KBb7ySg zq(thD?4x_n{m!}f-20t#Xa4l5<~P^0xnG)0IRreb!HG+c7H{Mhk$<>*Xj_+4MA!)S zLhQHwc6Dsk+GAUNploje)&P3bg^?FtvD8?YtgrYYp z(dHVRQkSOLmZi1Ju^+M>byw5cis9qP(?ctBRX(RrAJ27Zc^mks%ebH$K8-#rETAbi zk2SkAY$lrpgAUO5VZAONTK%{cp;-%C;L==h)RSx>U=Fd?5`wR~;AD$Rh!!{m{P5M# zFHY;%uqA*x%$9iJiNg9_+NrrCha`x*lbx6C3{BkF5-9I_YI zu>WL0OAG%muMz0%#w2h_kA8p5f})-Jh=XuymzwZmJ+rfn$1C{X;F#p%7$r<5#QQj& z^D!3})u!7Y^l?{4oFqR2PMx{~GvG0%^`Ak6@k$uJ5u}_q%?WOG4s{FViL&$npC1n3 zK%2obN<7Xa^62Y}?<^)Ln?%{RMnuXcQjH1fm_!}hIeBm3i~3>I zuu-ZR#t}E@NLB-+@BT?z%yt~Ja0!Bivt$Au{xXE<2{VCESYYNta|^Q`e#EI!Vw7|I zVRusJ)TYJz39RklD3fRZ&(QNo@tJmUkC{v#vg<;6}Yg% zOFscLaguUg6601d?&*tsJ1i~z6s`D}o<%8cWIH~~*OM7j_X5X)f#lS>+1p6amluU+RNuZ$( ztBKVp8v*WFtpjT;xdfZhq;X)RP8q8t2xMjobDx1?Rk1lU3C!9D-uM0w@V>X0(kFr0 zDiCd6EqZS1TUV_`Fm+B6Xh?Y+C5H7-1uc1sLR}}JC~tTRB1R0KHaT;bicW|4fJaCk z(o~3NeEu-7bWzUXL!p3rFv^+9*`vW(f4~nlW+{`wgJC(}8w$R`@nMhP4+T~1QZAFO z4D&fKxvV*JLN*0FLEr=Lo{wNgEM-F?D2lLb@c07$l!Gir>4oL2(ctA^=*{3qIzAVi z@OfBZcYHn;GziFI@@Kod5XWOMBR zxD$heyF%j3q;zIdJna_Uk$`wA2uj7EHg2j_3|b{y>rO-5){YG(cSjOE6H?EFcZ7EMx+xXV)v*xIw^L!L5JZTaZ{B7Z}e~R%WljF_M!Yo`&9K&7la-0vY zT7{LEZU<3wgfD=hkF*Lw`9f&RChr2zb3wuF4J`zPFuo?46jKdeJRc@%MDWPgbb{`W z2%h&aFMkVmQR0yy$xGV0+I6FA(<9M`5_I2I$GxtPyQG>v(b)HtkhC>HS4(vD7NF`A z^hB&9adJX}U(DzO67BsG{0{ev#(t>4X!pZX^D7WkT8V8| zh;}$xHw^@F9&eQLu-Xh*5n zH}(o9k>|ZmYamf*eAn|_z~c|XZLH?X)69^y{@~O>>EX;$K(cOXHsldxZNPKoAQ)eQ z8LwtaBw5sHeA}x9e}7)NB0Xac0IXv_g}|P)%&{N3({-y0s-nI1hb?Obs9iMrK4Evw zyQh9PaDPA?^Gc_@@eUtol?;g}wcUOwVQ-V{Z98Drl`<0^un)}-PbK=jQomQ63QPUr zc#j|&g{P^7?T$+JqdVPkdq>J{?m^S;{6j~g&n@-2#fu@SFBCsHCmQE)3Mr)?o3%*x zmK}3^#@?2)n|Yw!?Z$V?IqA$f@q%AEFBzseAK5jpnGMoCuyIYBG zmQkr^R2=7|9xmQFB^syps63_g^XGt@XPph`E;{1oi0te@gM!U7L3dA-;w6SiE=hPPSo0=&TmWW&V;L4fxJfx9BeImylf zKdXMmj$o7YdmfTGb*Fz{0-~6b*n|98ltc4nd#NymEyI=jkfi+tp&;467Ac|%U{i2 z$zHQb8NDYn3Sdc!EPl9pNM)l3CEa@DYPMbN{s;4n(Zf=>=#iIR^ zk%2*zt7tb+ZP70f@fs*l^aez{1rdHAv3DTW`^}aMCo!_U0?SXnxJZ`g8Ho82MEqc! zY$z(eS#WV4Ba^1y=J|US87CjzC&|Uc#J~WQXDD{s{9>O26W3P;eMa#jQ=kX{$zw5n diff --git a/init_pricing_plans.py b/init_pricing_plans.py new file mode 100644 index 0000000..b353a04 --- /dev/null +++ b/init_pricing_plans.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +Script to initialize default pricing plans in the database. +This should be run on a MASTER instance only. +""" + +import os +import sys +from app import app, db +from models import PricingPlan, User + +def init_pricing_plans(): + """Initialize default pricing plans""" + + # Check if this is a MASTER instance + if os.environ.get('MASTER', 'false').lower() != 'true': + print("Error: This script should only be run on a MASTER instance.") + print("Set MASTER=true environment variable to run this script.") + sys.exit(1) + + with app.app_context(): + # Check if pricing plans already exist + existing_plans = PricingPlan.query.count() + if existing_plans > 0: + print(f"Found {existing_plans} existing pricing plans. Skipping initialization.") + return + + # Get the first admin user + admin_user = User.query.filter_by(is_admin=True).first() + if not admin_user: + print("Error: No admin user found. Please create an admin user first.") + sys.exit(1) + + # Default pricing plans + default_plans = [ + { + 'name': 'Starter', + 'description': 'Perfect for small teams getting started', + 'monthly_price': 29.0, + 'annual_price': 23.0, + 'features': [ + 'Up to 5 rooms', + 'Up to 10 conversations', + '10GB storage', + 'Up to 10 managers', + 'Email support' + ], + 'button_text': 'Get Started', + 'button_url': '#', + 'is_popular': False, + 'is_custom': False, + 'is_active': True, + 'order_index': 1, + 'room_quota': 5, + 'conversation_quota': 10, + 'storage_quota_gb': 10, + 'manager_quota': 10, + 'admin_quota': 1 + }, + { + 'name': 'Professional', + 'description': 'Ideal for growing businesses', + 'monthly_price': 99.0, + 'annual_price': 79.0, + 'features': [ + 'Up to 25 rooms', + 'Up to 50 conversations', + '100GB storage', + 'Up to 50 managers', + 'Priority support' + ], + 'button_text': 'Get Started', + 'button_url': '#', + 'is_popular': True, + 'is_custom': False, + 'is_active': True, + 'order_index': 2, + 'room_quota': 25, + 'conversation_quota': 50, + 'storage_quota_gb': 100, + 'manager_quota': 50, + 'admin_quota': 3 + }, + { + 'name': 'Enterprise', + 'description': 'For large organizations with advanced needs', + 'monthly_price': 299.0, + 'annual_price': 239.0, + 'features': [ + 'Up to 100 rooms', + 'Up to 200 conversations', + '500GB storage', + 'Up to 200 managers', + '24/7 dedicated support' + ], + 'button_text': 'Get Started', + 'button_url': '#', + 'is_popular': False, + 'is_custom': False, + 'is_active': True, + 'order_index': 3, + 'room_quota': 100, + 'conversation_quota': 200, + 'storage_quota_gb': 500, + 'manager_quota': 200, + 'admin_quota': 10 + }, + { + 'name': 'Custom', + 'description': 'Tailored solutions for enterprise customers', + 'monthly_price': 0.0, + 'annual_price': 0.0, + 'features': [ + 'Unlimited rooms', + 'Unlimited conversations', + 'Unlimited storage', + 'Unlimited users', + 'Custom integrations', + 'Dedicated account manager' + ], + 'button_text': 'Contact Sales', + 'button_url': '#', + 'is_popular': False, + 'is_custom': True, + 'is_active': True, + 'order_index': 4, + 'room_quota': 0, + 'conversation_quota': 0, + 'storage_quota_gb': 0, + 'manager_quota': 0, + 'admin_quota': 0 + } + ] + + # Create pricing plans + for plan_data in default_plans: + plan = PricingPlan( + name=plan_data['name'], + description=plan_data['description'], + monthly_price=plan_data['monthly_price'], + annual_price=plan_data['annual_price'], + features=plan_data['features'], + button_text=plan_data['button_text'], + button_url=plan_data['button_url'], + is_popular=plan_data['is_popular'], + is_custom=plan_data['is_custom'], + is_active=plan_data['is_active'], + order_index=plan_data['order_index'], + room_quota=plan_data['room_quota'], + conversation_quota=plan_data['conversation_quota'], + storage_quota_gb=plan_data['storage_quota_gb'], + manager_quota=plan_data['manager_quota'], + admin_quota=plan_data['admin_quota'], + created_by=admin_user.id + ) + db.session.add(plan) + + try: + db.session.commit() + print("Successfully created default pricing plans:") + for plan_data in default_plans: + print(f" - {plan_data['name']}: €{plan_data['monthly_price']}/month") + print("\nYou can now configure these plans in the admin settings.") + except Exception as e: + db.session.rollback() + print(f"Error creating pricing plans: {e}") + sys.exit(1) + +if __name__ == '__main__': + init_pricing_plans() \ No newline at end of file diff --git a/migrations/versions/add_pricing_plans_table.py b/migrations/versions/add_pricing_plans_table.py new file mode 100644 index 0000000..8abb32c --- /dev/null +++ b/migrations/versions/add_pricing_plans_table.py @@ -0,0 +1,62 @@ +"""add pricing plans table + +Revision ID: add_pricing_plans_table +Revises: add_help_articles_table +Create Date: 2024-12-19 11:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + + +# revision identifiers, used by Alembic. +revision = 'add_pricing_plans_table' +down_revision = 'add_help_articles_table' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + result = conn.execute(text(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'pricing_plans' + ); + """)) + exists = result.scalar() + if not exists: + op.create_table('pricing_plans', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('monthly_price', sa.Float(), nullable=False), + sa.Column('annual_price', sa.Float(), nullable=False), + sa.Column('features', sa.JSON(), nullable=False), + sa.Column('is_popular', sa.Boolean(), nullable=True, server_default='false'), + sa.Column('is_custom', sa.Boolean(), nullable=True, server_default='false'), + sa.Column('button_text', sa.String(length=50), nullable=True, server_default="'Get Started'"), + sa.Column('button_url', sa.String(length=200), nullable=True, server_default="'#'"), + sa.Column('order_index', sa.Integer(), nullable=True, server_default='0'), + sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + # Create indexes for better performance + op.create_index('idx_pricing_plans_active', 'pricing_plans', ['is_active']) + op.create_index('idx_pricing_plans_order', 'pricing_plans', ['order_index']) + op.create_index('idx_pricing_plans_popular', 'pricing_plans', ['is_popular']) + op.create_index('idx_pricing_plans_created_at', 'pricing_plans', ['created_at']) + + +def downgrade(): + op.drop_index('idx_pricing_plans_active', table_name='pricing_plans') + op.drop_index('idx_pricing_plans_order', table_name='pricing_plans') + op.drop_index('idx_pricing_plans_popular', table_name='pricing_plans') + op.drop_index('idx_pricing_plans_created_at', table_name='pricing_plans') + op.drop_table('pricing_plans') \ No newline at end of file diff --git a/migrations/versions/add_quota_fields_to_pricing_plans.py b/migrations/versions/add_quota_fields_to_pricing_plans.py new file mode 100644 index 0000000..b266fb3 --- /dev/null +++ b/migrations/versions/add_quota_fields_to_pricing_plans.py @@ -0,0 +1,55 @@ +"""add quota fields to pricing plans + +Revision ID: add_quota_fields +Revises: add_pricing_plans_table +Create Date: 2024-12-19 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + + +# revision identifiers, used by Alembic. +revision = 'add_quota_fields' +down_revision = 'add_pricing_plans_table' +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 ('room_quota', 'conversation_quota', 'storage_quota_gb', 'manager_quota', 'admin_quota') + """)) + existing_columns = [row[0] for row in result.fetchall()] + + # Add quota columns if they don't exist + if 'room_quota' not in existing_columns: + op.add_column('pricing_plans', sa.Column('room_quota', sa.Integer(), nullable=True, server_default='0')) + + if 'conversation_quota' not in existing_columns: + op.add_column('pricing_plans', sa.Column('conversation_quota', sa.Integer(), nullable=True, server_default='0')) + + if 'storage_quota_gb' not in existing_columns: + op.add_column('pricing_plans', sa.Column('storage_quota_gb', sa.Integer(), nullable=True, server_default='0')) + + if 'manager_quota' not in existing_columns: + op.add_column('pricing_plans', sa.Column('manager_quota', sa.Integer(), nullable=True, server_default='0')) + + if 'admin_quota' not in existing_columns: + op.add_column('pricing_plans', sa.Column('admin_quota', sa.Integer(), nullable=True, server_default='0')) + + +def downgrade(): + # Remove quota columns + op.drop_column('pricing_plans', 'admin_quota') + op.drop_column('pricing_plans', 'manager_quota') + op.drop_column('pricing_plans', 'storage_quota_gb') + op.drop_column('pricing_plans', 'conversation_quota') + op.drop_column('pricing_plans', 'room_quota') \ No newline at end of file diff --git a/models.py b/models.py index 2e39b4b..fff20a6 100644 --- a/models.py +++ b/models.py @@ -586,4 +586,92 @@ class HelpArticle(db.Model): if article.category not in grouped: grouped[article.category] = [] grouped[article.category].append(article) - return grouped \ No newline at end of file + return grouped + +class PricingPlan(db.Model): + __tablename__ = 'pricing_plans' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + description = db.Column(db.Text) + monthly_price = db.Column(db.Float, nullable=False) + annual_price = db.Column(db.Float, nullable=False) + features = db.Column(db.JSON, nullable=False) # List of feature strings + is_popular = db.Column(db.Boolean, default=False) + is_custom = db.Column(db.Boolean, default=False) + button_text = db.Column(db.String(50), default='Get Started') + button_url = db.Column(db.String(200), default='#') + order_index = db.Column(db.Integer, default=0) + is_active = db.Column(db.Boolean, default=True) + # Quota fields + room_quota = db.Column(db.Integer, default=0) # 0 = unlimited + conversation_quota = db.Column(db.Integer, default=0) # 0 = unlimited + storage_quota_gb = db.Column(db.Integer, default=0) # 0 = unlimited, stored in GB + manager_quota = db.Column(db.Integer, default=0) # 0 = unlimited + admin_quota = db.Column(db.Integer, default=0) # 0 = unlimited + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + + # Relationships + creator = db.relationship('User', backref=db.backref('created_pricing_plans', cascade='all, delete-orphan'), foreign_keys=[created_by]) + + def __repr__(self): + return f'' + + @classmethod + def get_active_plans(cls): + """Get all active pricing plans ordered by order_index""" + return cls.query.filter_by(is_active=True).order_by(cls.order_index).all() + + @classmethod + def get_popular_plan(cls): + """Get the plan marked as most popular""" + return cls.query.filter_by(is_active=True, is_popular=True).first() + + def get_storage_quota_bytes(self): + """Get storage quota in bytes""" + if self.storage_quota_gb == 0: + return 0 # Unlimited + return self.storage_quota_gb * 1024 * 1024 * 1024 # Convert GB to bytes + + def format_quota_display(self, quota_type): + """Format quota for display""" + if quota_type == 'room_quota': + return 'Unlimited' if self.room_quota == 0 else f'{self.room_quota} rooms' + elif quota_type == 'conversation_quota': + return 'Unlimited' if self.conversation_quota == 0 else f'{self.conversation_quota} conversations' + elif quota_type == 'storage_quota_gb': + return 'Unlimited' if self.storage_quota_gb == 0 else f'{self.storage_quota_gb}GB' + elif quota_type == 'manager_quota': + return 'Unlimited' if self.manager_quota == 0 else f'{self.manager_quota} managers' + elif quota_type == 'admin_quota': + return 'Unlimited' if self.admin_quota == 0 else f'{self.admin_quota} admins' + return 'Unknown' + + def check_quota(self, quota_type, current_count): + """Check if a quota would be exceeded""" + if quota_type == 'room_quota': + return self.room_quota == 0 or current_count < self.room_quota + elif quota_type == 'conversation_quota': + return self.conversation_quota == 0 or current_count < self.conversation_quota + elif quota_type == 'storage_quota_gb': + return self.storage_quota_gb == 0 or current_count < self.storage_quota_gb + elif quota_type == 'manager_quota': + return self.manager_quota == 0 or current_count < self.manager_quota + elif quota_type == 'admin_quota': + return self.admin_quota == 0 or current_count < self.admin_quota + return True + + def get_quota_remaining(self, quota_type, current_count): + """Get remaining quota""" + if quota_type == 'room_quota': + return float('inf') if self.room_quota == 0 else max(0, self.room_quota - current_count) + elif quota_type == 'conversation_quota': + return float('inf') if self.conversation_quota == 0 else max(0, self.conversation_quota - current_count) + elif quota_type == 'storage_quota_gb': + return float('inf') if self.storage_quota_gb == 0 else max(0, self.storage_quota_gb - current_count) + elif quota_type == 'manager_quota': + return float('inf') if self.manager_quota == 0 else max(0, self.manager_quota - current_count) + elif quota_type == 'admin_quota': + return float('inf') if self.admin_quota == 0 else max(0, self.admin_quota - current_count) + return 0 \ No newline at end of file diff --git a/routes/__pycache__/admin.cpython-313.pyc b/routes/__pycache__/admin.cpython-313.pyc index 6b9b3b40b571a7e248f6a55dec2668d8a934a0e5..b96373d259460da77de61de6787517c6c957256f 100644 GIT binary patch delta 8766 zcmcIpYj7Lab>77S#Nz#azZN7&k(MZmk|>G~J!n#IN|v;O#gvR0hy-c45U4Cb*-@QA zJn5wLj3zd{?U3xK7Cla5dDS9!jN6%Jpvu#SCQWT$--iv$BIp5{`Z<62sZ&H6xr&AN~xL^3K=YRM4S-n&CEo!!T z#LE(_{D6@&j*#^PTQ_3rkf9YpaOM#+U$j_f|1?Nh%i^6NWh;rimkd(&vV06=M_E1# zva>8-1KCxUe+ja?EN_+tDNk9BgX}HIA=bmvZ6vGel8q=>ubb#7jYKLrpJ?b~wUVK~ zhM|Ae&>|TIY8VDq4Q-MkQ^Sxc88)#&wq7!9s9_kKl(&SI-jMx7O{$l^tlMEEcQ5^s z@1mOgcJAtSJ_(uFXlFH2BQ@+OsR`*FKdTkhVwsRXY-VM3 zqFO8y3jVoHzpP&B%FgOpgE)H0s!kk;mNl}{R_5c1AP!m2npunZPJ$L~&8$`Ipu7UA zRh6wur506Q)?TI34Ew4l%&-oY7QZN(C$zN;oiz+~wG3S~4E41P-8Bpi4;wb&v3qEY z#;~lpJevq?o^T!C-sAFlH<02iSRd|!6#-X#l~awmtuy>J2ilx94d9OGiAu(N{hO9~ zdLkN0O>@!YecAAr3z`$tsnk?rG!=a*mAy56@{%yTLR~UF z7J+5s+R>yNp%-Br!ghoW04-KwEl)<0sVGUZS9a;fzjk<1jrrt_^nxbV=N+{7IjiU)fDNakoIw8Wq%e(7Xp> zC3%A4-(MR_e^y7BEOY8v^_+fIKmXDNN6y%uH+E)?ojGIIrDwCo{%`F2L}Ohs*yoj* z;By(rXwLAJ%!z2m5Y5ohqP$Dd_V;uX^kQ_p_VTMQ=M90ZA+T7NGpw1Buh@Na@!5FZ z-juaBEq*;`@0ii7IDO|P&&LEh|EZ5wE;p1HxbTy%foLcjlaZGde!9 z$CLhdSZnK>TURXZ`8}D&T^Y~roMq34mW*XEqaB1n*&K8GXZM#T@|~ma9DnQhJHu}c zU-abL_h#Go<{J0qte`!TwI0b?*}Qcqb9`tP|^~vP*I1Ho^aL|4Ja8=GNEK998A%Ik`*NzN_NybP;wH? z#-a-)H{oAj^q}O$&-qaDW6uGQiVWeZU+nusT`_1jHmEpc)vxJa)xVy)MSJ1e}{~i`yHV6 z%Q2%1lSkD;?7mA`^kG7bK?zw{hC#oft|A&Kg`khlNqATq0=5e5j)bfjogsGf*&Ztf zZ4LiFy>@KVLfdVVwHriTSa#GdzPnn2Mh_b2YKtg=#sII+mU@SrgwO1^CZN>>_EPF0 zS`!%8&7fgcTSN&o7SPm-_0%FgH7h8&uXbv^u~ZeQwnQ8YgJh zRW(BuG%nDf@2`45FkEvUfx+4Mz- zj{f;nK7ei-oFKH&go#uk6NvEk?rRn;$-@&~jH{ zbE@wVHlwCU*i@ST{A@j;wcR5~MVpWm@V3keq5ysO5`}97>_Mdu)viMmHy`wXI$SSFOiZn1B4y83#e z`V+wliY>iHS6jI%Ik)N|t(CQj+%uuB*7_c%*j0<+bBJFq{}S5jKGry^EOf&GabnfB zYio1sN88t?*pIfa$+SObJl-12-1KPsMqdqkb6762ZGp+txCPVxiY*wxEjZL4H;U^r z#KujMJQg=gax#uwQ+S5H8JB*GAwG`Gv()}}Tnh0+OK~JVrS_|FDZmfqhT_uB9r}4( z+G#`g;vVsZurW-^;;7 zOzbaF`VqoqgewRiAbcJtBe=gt6J)W~v~~K_cmxmapmnPlPSRIlv zkKsN-5Q6dFpi~v6CH~6YKh2q65p!h8g)S+6QlA>4ulEbl#!W$TIoKZeta>+>4ClO6!(`JBSZL`%F-LwB>0U zijQ}wpHWdaR2|RAsTCkm`GvgKkRHylfyx=&#+v~6e?n)L2~i(KDofBpse6i z#wT_0CGV@kt1kw*2LH>gfpm-M30&=}1AG&vQuC5eQPT?wo(ml{@);yF4ZvtDa0eMp zMIR(Jx54PQ(TPz=YX1M%a{4E6V&x5%H=XJS^uUeKy@HX;73}j;~UMy0C)P_W+TP7zm>BLV-0@>~^C-Mpjd^ zj&NNE1(SjYPM?&fAtB&@(AlCe;eXWlPdfveD!RZoZS6=aYn&&mbe@!bO5r>~S#h3h zPkNr<-yN}Ne7vAI9f?mzTPW`8W*GHqR&++UF9E6;-0P!VIRj!2XpeTY?~BUaZO(GevUJ^!(Z9$S>rBcXAd09 z(bBlNPr>Y4csl#PnA2%{qSI-x!|nRt8Une!hTx1Gt}W*1S$ZBW?1M9k2Wz)A#*A}k zPP0?E{8${#csg>HEtjZ_r6;59xkDRXm(Pc9(G9}sem3iX%aWlCJ%sn9?Q3DKaJmPY z@pNmTxVoVe@LIEa*GB5v#*SUB)b%wa$k$sHfGzd>_Xk@QLUzjkVz61p-Q+C?cW>wL z9}V0J0w!qOZ3J9L?k5O;f*@S*(7o_qndx|RJNK{92Iq%JzAfW_=b)GR8KGI4J9v&% z7UdRleeo&&_918bX_9QaM-eh3LQheS_2OO`AyfXI=M_&z*Kt{K>BVn&GUT3R(r{PS zP+`57EXtu?+)qBrdV7^M)QdYS`=*avI)k0>7dm%Tm>+=7pb8_!1G7A>}@iPl73pX`)7qSPY{QLoi#6fGHD5mVu#2Fh(#) zb+V$q*u)*Otin)^@Wi(YtRgVZWK~85R#7-F7RVC=^Bi48CQoCOXBCI@&H;H6aFwh~ z3apZFo-L3k1y@waTrWZL}8H$Npcr+BhiwZ`P+YG=EZ3`MI! zLE;Z2ZgJQEWlM8X?TU5)xj+LLigyFa56p~=jQ1IIFEZ%fXE3?X;C-FJ`!)mTT?X0B R%r$cuxjwQ8GwOkj0RRRbX^;Q_ diff --git a/routes/__pycache__/main.cpython-313.pyc b/routes/__pycache__/main.cpython-313.pyc index a91cf61459eaaf04b45be4246d96894fc820d52e..0aa31105d0d58c116f68c30e8d9d30a7462947f8 100644 GIT binary patch delta 2922 zcmb7^dsLI>9mn4<2`@qjWRRd}fN)>H5bhaeoX#6|s1Qx#1r&I+q$`0Z51aFYpZRYo*vtVo8oEh_WS}a==RTYPCoDN z`8~hub9<{#yf`363~RMv0{^|YrgF^`*RcrO7eOf++1`;P|0re(%Z|cocAyxB<2SxI zD4MK-%it6o(;iwb2(?9m;D~g@E-;s6xMEC#GiHfVey2NH&b<_^=7Xj3;z66nQ00$K z8DtFU!yMDecRp&(a@>$rn?)1!a|SJ`mUD&;@=HT{WXW#~6>xs-XX-G?RetXB^KZii z_j$IEXJX5Eq{(cV9N$G&ajwbp<8r`CwvNOEIaPAbi2l)lw#t3a$B~vTIA-u#J@+($ zZw(wqKJ#Qib5G(kaZZ6VUalCi#U?m4TsAqwITM{)&Ln3vXR^G1Bsnz2Y2b2dRj2&! zNFvhZ>mwBilWn6*L%kvGQgdCit4ZEI+7x{Tf?$SMV;|SqTWVeM@aU2V?SxTHJJ-17 zxwq0$!eGL5W<88xx%<}p)4gJATYW=|Tl$d4@M_wm`Ua_{*;U`LR$g{ni8{x%(s=@{ zo_VU&E1SlW)n~azJ~*n6k-RkbcfujUuLu|9C&nz{(mzP1uc@^iNGUSma{ z(Q(e`=ruaGEt22*=F5#yx}yHfAM|CGp3f}xsrcxM<)J=H*?CKuFPM)8MZuis3n6KW zFtf-P%B5RjLP7C?(%&pRwD6DWF`tGe!-W*HPfJpSkWt`^BuOV^+I-(5DT-#JNzw~h zB|Za5F+#G{HaJ8JY)y@3&jzEB&5+>Qb$`%V2dkcszWY-PVrw`dUwe#og*@uM>ph zFW&u&D#b@DJV+QK3=>8Oql8<8v+QZ$6wb2pAUH$*s^+_41+#}BP5Ceg-Kqrg^s3zM z7U@6Hyebbr5aZEUmxvF<&&cmCJLW;E;t9bVQFD=kkDm_qCoNXsHZw-x5phEGcmytK z`X|}gBT*6_I8B8D+h2_s_F5!zL)5`M`Wdk9M9ft5I`pUz!rqR?TJbiIV2a+u+fWX#uCA@Mz1G!Pb8jKd;gX5!`h;+W zaFy^W;WNVLglmL<60Q?&aCpO8+M3#G#`mFK3ZoR^99?O1##5N?r7*D_2{?#;mTf{s z*8i7?@r6of7fd*c1yvMe;k126j3h5zJ&k7<)5|-~89v{eu&a4Uxo>ZE`AE|Q_O^u0%Evr$BHFY0ScJWbHy__= z=3b!1^dFr*lIYzqhfW+LQuGoEX`>%}QfPu4&B=iWj1|wq=KCr+H4CM7N)_BMX6Wfu3Wv z)K6=5g)qSORr8bZ`x7-tQkB+Gyt}0ToWradw`n8KW}5nzrs_#rM!E(r@$(RfHg3t* ztVX5SOZ{G?e)E-YS7W;hb?li&EYwW$XIC4s7GEiqO^6hAf$Xi6!liV!q8`vGAG`1` z>gi)Qrr85l*QiFdR7a%@5Z01gC*gk;DvlTWpgAk`@CRY3@`?xB)w&1b)Uh9TVgn?0 zsuS_>u z;)#tI2*R_>y9tfa&rrQPIlN+hTWf8zODboM&HUybQ+92}Dz(8MeH!(*o=i6o)-u&r ztW4NQ^3MqTKhd>^hMNd(wqYy3^PAcEt!Na_Q%%2?54wM-bZx^PG0&g4n+*LsPOB*~ z8U|MAzp|F@-r4FYLl4+*Ll>#qUDOZzD4p^NYoVJqPmggt~Kgx?WP68=DVm#_uwoBe!Q zPb(D%uwEV0h~T1Ol_2T~CZC$dKD+YiOW33imM-%u*-sARZOmYqJ^aC=`zu+f`%1R5 mhaal%*t#CX;yLzw59Z=D^Y$PPAF;6>yczmLA~aG7H~u%1(j_VY delta 2699 zcmZ{mdr(x@9mnr?ce$`E;u2W}vbf7bGn;h*i>rdtL|%ymL;|QlWu>qPW?{E?7fUt9 zCfHP(nZ$Av&M-9!rZI`Hq{B3K+G(Tx$LS-Mg2u;Uqccs$#5B5sP;o?iem5Awrgw(V z{oZqauX}#?oaO8V@sH=k=+RlTA_ac6S5!A0^ZX`y*;UQnNY>j8J2TEhe*cG8Pm7wG zNVz)GY%Gt<-XI81q0S}ygc^%&)jN*YQ_B?zGhfela;(?B-Sv^ zSKgEwtEtq|EqaD^t!lk%Bo=nLXT-@lpfYTf^a_zBa#P~vF%hvyVB`>(8JTd@5kp4kP<4J8MX;8j)D<$m| zjr@y`NS_fd5k4oJqlv!|4iMfV49QPSI5d6}>+3?S{A8I4qrSGDG2{B7{X*Aw5^)k%DtY zZ{a(|2Z~SVZiNgq86~9JLbFIk3y(WPvq>2R`?63BsYhsb4yjlnEhl6mHCISU56!D0 z8Ap-hNhJu2^ALKJq*-vR2qlu5PjM`ytityS5K7|mX0l*gdSrGeh3k+IqtBWc$r4O1 z_(i@Re~q12sqf!+_mVdCD_V+w6TT)~BU~qh2sa3)Sf2)e#woT%i%J|~*#I8tMY>1}TiZ z!rALqDENdGC!zHJJ0~)q5h*N`gb$Ff^d)0Vk6$yk2s!huJj|DTX#|@lD0=(>so56{ z3$*z>ZX2XjHnIriIKT=Wco$Z7-GMqBRLat@3`l03=`f26cz|Gu{HI0}JCTm%ILs!} zk!!Z`VOyA#N>wf;I0%mu(%AY8Y{Yx)w;8wqtMb}nv}lpRCYK;}#)2<$Vxi%|{#wp9 zI#D1_#dz0=61<^|J8{>LKSb-K_VWxW+FmyORBvfukg$|`^6<{1339Zh@V?pPfs3*1 zauwp)tGRe)Mlqk|q7XyOo`+R9ue9Xh<8TzP>}N25)hx0YnZ`nTIdxO3Nv^PxVibxN z{+0VJ?4Fl@cOMmFua2A7lwpmyn#@bs`(;SQ6?Uc!MW|9N&*HSm`n4g(hUP zeN{Nh-HNNRWPT;Nt>Xxa-nM|REg-F@kt_5fqK5H38PAkz-XNcHy&CB{oMjdl()ewS z3u`gT_Pdb4pMLM+z3f-an~Lq0hti27TVre)Zze?xD66Saw857WAR;iUw+YIbjIB(sP z+hI3MOTxBp$Bu9h6|;|)FjL{h9Ms}Pc4jA@Lbqc6DK2R6b2k1G8t43sN0NTQ5fmG| zEp^SU(rV`IKn@NmM?0`lXHp}_(So&;=}!oLX4#D&q;4au{V+nT97tkpFjVgOv?(?ryx;xP$W~wE;OfG7J)!)1>dVH|>|C9fRdw7Wf*1HEc z(8;=fi8hlOMs+q*we(KXtIXVmVR4*N+U4G(D_L(7qS>7e*c6|Pvtfq!$m|f|Fe~f9 z9PtS0qijnLYQ=RC+;5ON`j|4-gY|$%Dftyf0ZCc)CWe8JSmJ&ZV24t_A5QH2f->JE z+#^I#ECV5dkV{Z^dJ(BoLJgsg;3jM#_y}!;7YHvAUL(9tI7s+_&`5z~d z)Jr-ug3*nhI{$O79|s!K>itrq`tOP~NYfMab^}}m^^I+<^#QM!|8}O+%q#5JVdPs* jQlw6@%AwJ0GL2ZtQ?_~B-imBJjh66i diff --git a/routes/admin.py b/routes/admin.py index f885eb0..4a90084 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -3,6 +3,7 @@ from flask_login import login_required, current_user from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle import os from datetime import datetime +import json admin = Blueprint('admin', __name__) @@ -438,4 +439,240 @@ def get_help_article(article_id): 'order_index': article.order_index } - return jsonify({'article': article_data}) \ No newline at end of file + return jsonify({'article': article_data}) + +@admin.route('/api/admin/pricing-plans', methods=['POST']) +@login_required +def create_pricing_plan(): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Check if this is a MASTER instance + is_master = os.environ.get('MASTER', 'false').lower() == 'true' + if not is_master: + return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403 + + try: + from models import PricingPlan + + # Get form data + name = request.form.get('name') + description = request.form.get('description') + monthly_price = float(request.form.get('monthly_price')) + 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 quota fields + room_quota = int(request.form.get('room_quota', 0)) + conversation_quota = int(request.form.get('conversation_quota', 0)) + storage_quota_gb = int(request.form.get('storage_quota_gb', 0)) + manager_quota = int(request.form.get('manager_quota', 0)) + admin_quota = int(request.form.get('admin_quota', 0)) + + # Validate required fields + if not name or not features: + return jsonify({'error': 'Name and features are required'}), 400 + + # Get the highest order index + max_order = db.session.query(db.func.max(PricingPlan.order_index)).scalar() or 0 + + # Create new pricing plan + plan = PricingPlan( + name=name, + description=description, + monthly_price=monthly_price, + annual_price=annual_price, + features=features, + button_text=button_text, + button_url=button_url, + is_popular=is_popular, + is_custom=is_custom, + is_active=is_active, + room_quota=room_quota, + conversation_quota=conversation_quota, + storage_quota_gb=storage_quota_gb, + manager_quota=manager_quota, + admin_quota=admin_quota, + order_index=max_order + 1, + created_by=current_user.id + ) + + db.session.add(plan) + db.session.commit() + + return jsonify({'success': True, 'message': 'Pricing plan created successfully'}) + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + +@admin.route('/api/admin/pricing-plans/', methods=['GET']) +@login_required +def get_pricing_plan(plan_id): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + from models import PricingPlan + + plan = PricingPlan.query.get(plan_id) + if not plan: + return jsonify({'error': 'Pricing plan not found'}), 404 + + return jsonify({ + 'success': True, + 'plan': { + 'id': plan.id, + 'name': plan.name, + 'description': plan.description, + 'monthly_price': plan.monthly_price, + 'annual_price': plan.annual_price, + 'features': plan.features, + 'button_text': plan.button_text, + 'button_url': plan.button_url, + 'is_popular': plan.is_popular, + 'is_custom': plan.is_custom, + 'is_active': plan.is_active, + 'order_index': plan.order_index, + 'room_quota': plan.room_quota, + 'conversation_quota': plan.conversation_quota, + 'storage_quota_gb': plan.storage_quota_gb, + 'manager_quota': plan.manager_quota, + 'admin_quota': plan.admin_quota + } + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@admin.route('/api/admin/pricing-plans/', methods=['PUT']) +@login_required +def update_pricing_plan(plan_id): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Check if this is a MASTER instance + is_master = os.environ.get('MASTER', 'false').lower() == 'true' + if not is_master: + return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403 + + try: + from models import PricingPlan + + plan = PricingPlan.query.get(plan_id) + if not plan: + return jsonify({'error': 'Pricing plan not found'}), 404 + + # Get form data + name = request.form.get('name') + description = request.form.get('description') + monthly_price = float(request.form.get('monthly_price')) + 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 quota fields + room_quota = int(request.form.get('room_quota', 0)) + conversation_quota = int(request.form.get('conversation_quota', 0)) + storage_quota_gb = int(request.form.get('storage_quota_gb', 0)) + manager_quota = int(request.form.get('manager_quota', 0)) + admin_quota = int(request.form.get('admin_quota', 0)) + + # Validate required fields + if not name or not features: + return jsonify({'error': 'Name and features are required'}), 400 + + # Update plan + plan.name = name + plan.description = description + plan.monthly_price = monthly_price + plan.annual_price = annual_price + plan.features = features + plan.button_text = button_text + plan.button_url = button_url + plan.is_popular = is_popular + plan.is_custom = is_custom + plan.is_active = is_active + plan.room_quota = room_quota + plan.conversation_quota = conversation_quota + plan.storage_quota_gb = storage_quota_gb + plan.manager_quota = manager_quota + plan.admin_quota = admin_quota + + db.session.commit() + + return jsonify({'success': True, 'message': 'Pricing plan updated successfully'}) + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + +@admin.route('/api/admin/pricing-plans/', methods=['DELETE']) +@login_required +def delete_pricing_plan(plan_id): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Check if this is a MASTER instance + is_master = os.environ.get('MASTER', 'false').lower() == 'true' + if not is_master: + return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403 + + try: + from models import PricingPlan + + plan = PricingPlan.query.get(plan_id) + if not plan: + return jsonify({'error': 'Pricing plan not found'}), 404 + + db.session.delete(plan) + db.session.commit() + + return jsonify({'success': True, 'message': 'Pricing plan deleted successfully'}) + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + +@admin.route('/api/admin/pricing-plans//status', methods=['PATCH']) +@login_required +def update_pricing_plan_status(plan_id): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Check if this is a MASTER instance + is_master = os.environ.get('MASTER', 'false').lower() == 'true' + if not is_master: + return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403 + + try: + from models import PricingPlan + + plan = PricingPlan.query.get(plan_id) + if not plan: + return jsonify({'error': 'Pricing plan not found'}), 404 + + data = request.get_json() + field = data.get('field') + value = data.get('value') + + if field not in ['is_active', 'is_popular', 'is_custom']: + return jsonify({'error': 'Invalid field'}), 400 + + setattr(plan, field, value) + db.session.commit() + + return jsonify({'success': True, 'message': 'Plan status updated successfully'}) + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/routes/main.py b/routes/main.py index b97bc07..0e79760 100644 --- a/routes/main.py +++ b/routes/main.py @@ -1122,7 +1122,7 @@ def init_routes(main_bp): active_tab = request.args.get('tab', 'colors') # Validate tab parameter - valid_tabs = ['colors', 'general', 'email_templates', 'mails', 'security', 'events', 'debugging', 'smtp', 'connections'] + valid_tabs = ['colors', 'general', 'email_templates', 'mails', 'security', 'events', 'debugging', 'smtp', 'connections', 'pricing'] if active_tab not in valid_tabs: active_tab = 'colors' @@ -1180,6 +1180,12 @@ def init_routes(main_bp): current_page = mails.page users = User.query.order_by(User.username).all() + # Get pricing plans for the pricing tab (only for MASTER instances) + pricing_plans = [] + if active_tab == 'pricing': + from models import PricingPlan + pricing_plans = PricingPlan.query.order_by(PricingPlan.order_index).all() + if request.method == 'GET': company_form.company_name.data = site_settings.company_name company_form.company_website.data = site_settings.company_website @@ -1210,6 +1216,7 @@ def init_routes(main_bp): nginx_settings=nginx_settings, git_settings=git_settings, cloudflare_settings=cloudflare_settings, + pricing_plans=pricing_plans, csrf_token=generate_csrf()) @main_bp.route('/settings/update-smtp', methods=['POST']) diff --git a/routes/public.py b/routes/public.py index 10ee477..bc1a276 100644 --- a/routes/public.py +++ b/routes/public.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, redirect, url_for, request -from models import SiteSettings, HelpArticle +from models import SiteSettings, HelpArticle, PricingPlan import os def init_public_routes(public_bp): @@ -8,6 +8,11 @@ def init_public_routes(public_bp): site_settings = SiteSettings.query.first() return dict(site_settings=site_settings) + @public_bp.context_processor + def inject_pricing_plans(): + """Make PricingPlan model available in templates""" + return dict(PricingPlan=PricingPlan) + @public_bp.route('/features') def features(): """Features page""" diff --git a/static/js/settings/pricing.js b/static/js/settings/pricing.js new file mode 100644 index 0000000..653b2b6 --- /dev/null +++ b/static/js/settings/pricing.js @@ -0,0 +1,310 @@ +document.addEventListener('DOMContentLoaded', function() { + // Feature management for add form + const addFeatureBtn = document.getElementById('addFeatureBtn'); + const featuresContainer = document.getElementById('featuresContainer'); + + if (addFeatureBtn) { + addFeatureBtn.addEventListener('click', function() { + addFeatureField(featuresContainer); + }); + } + + // Feature management for edit form + const editAddFeatureBtn = document.getElementById('editAddFeatureBtn'); + const editFeaturesContainer = document.getElementById('editFeaturesContainer'); + + if (editAddFeatureBtn) { + editAddFeatureBtn.addEventListener('click', function() { + addFeatureField(editFeaturesContainer); + }); + } + + // Remove feature buttons + document.addEventListener('click', function(e) { + if (e.target.classList.contains('remove-feature-btn')) { + e.target.closest('.input-group').remove(); + } + }); + + // Add Pricing Plan Form + const addPricingPlanForm = document.getElementById('addPricingPlanForm'); + if (addPricingPlanForm) { + addPricingPlanForm.addEventListener('submit', function(e) { + e.preventDefault(); + submitPricingPlanForm(this, 'POST', '/api/admin/pricing-plans'); + }); + } + + // Edit Pricing Plan Form + const editPricingPlanForm = document.getElementById('editPricingPlanForm'); + if (editPricingPlanForm) { + editPricingPlanForm.addEventListener('submit', function(e) { + e.preventDefault(); + const planId = document.getElementById('editPlanId').value; + submitPricingPlanForm(this, 'PUT', `/api/admin/pricing-plans/${planId}`); + }); + } + + // Edit Plan Buttons + document.addEventListener('click', function(e) { + if (e.target.classList.contains('edit-plan-btn')) { + const planId = e.target.getAttribute('data-plan-id'); + loadPlanForEdit(planId); + } + }); + + // Delete Plan Buttons + document.addEventListener('click', function(e) { + if (e.target.classList.contains('delete-plan-btn')) { + const planId = e.target.getAttribute('data-plan-id'); + const planName = e.target.closest('.card').querySelector('.card-header h5').textContent; + showDeleteConfirmation(planId, planName); + } + }); + + // Confirm Delete Button + const confirmDeleteBtn = document.getElementById('confirmDeletePlan'); + if (confirmDeleteBtn) { + confirmDeleteBtn.addEventListener('click', function() { + const planId = this.getAttribute('data-plan-id'); + deletePricingPlan(planId); + }); + } + + // Toggle switches + document.addEventListener('change', function(e) { + if (e.target.classList.contains('plan-status-toggle')) { + const planId = e.target.getAttribute('data-plan-id'); + const isActive = e.target.checked; + updatePlanStatus(planId, 'is_active', isActive); + } + + if (e.target.classList.contains('plan-popular-toggle')) { + const planId = e.target.getAttribute('data-plan-id'); + const isPopular = e.target.checked; + updatePlanStatus(planId, 'is_popular', isPopular); + } + + if (e.target.classList.contains('plan-custom-toggle')) { + const planId = e.target.getAttribute('data-plan-id'); + const isCustom = e.target.checked; + updatePlanStatus(planId, 'is_custom', isCustom); + } + }); +}); + +function addFeatureField(container) { + const featureGroup = document.createElement('div'); + featureGroup.className = 'input-group mb-2'; + featureGroup.innerHTML = ` + + + `; + container.appendChild(featureGroup); +} + +function submitPricingPlanForm(form, method, url) { + const formData = new FormData(form); + + // Convert features array + const features = []; + form.querySelectorAll('input[name="features[]"]').forEach(input => { + if (input.value.trim()) { + features.push(input.value.trim()); + } + }); + + // Remove features from formData and add as JSON + formData.delete('features[]'); + formData.append('features', JSON.stringify(features)); + + // Convert checkboxes to boolean values + const checkboxes = ['is_popular', 'is_custom', 'is_active']; + checkboxes.forEach(field => { + formData.set(field, formData.get(field) === 'on'); + }); + + fetch(url, { + method: method, + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification('Pricing plan saved successfully!', 'success'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + showNotification(data.error || 'Error saving pricing plan', 'error'); + } + }) + .catch(error => { + console.error('Error:', error); + showNotification('Error saving pricing plan', 'error'); + }); +} + +function loadPlanForEdit(planId) { + fetch(`/api/admin/pricing-plans/${planId}`) + .then(response => response.json()) + .then(data => { + if (data.success) { + const plan = data.plan; + + // Populate form fields + document.getElementById('editPlanId').value = plan.id; + document.getElementById('editPlanName').value = plan.name; + document.getElementById('editPlanDescription').value = plan.description || ''; + 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('editIsPopular').checked = plan.is_popular; + document.getElementById('editIsCustom').checked = plan.is_custom; + document.getElementById('editIsActive').checked = plan.is_active; + + // Populate quota fields + document.getElementById('editRoomQuota').value = plan.room_quota || 0; + document.getElementById('editConversationQuota').value = plan.conversation_quota || 0; + document.getElementById('editStorageQuota').value = plan.storage_quota_gb || 0; + document.getElementById('editManagerQuota').value = plan.manager_quota || 0; + document.getElementById('editAdminQuota').value = plan.admin_quota || 0; + + // Populate features + const editFeaturesContainer = document.getElementById('editFeaturesContainer'); + editFeaturesContainer.innerHTML = ''; + + plan.features.forEach(feature => { + const featureGroup = document.createElement('div'); + featureGroup.className = 'input-group mb-2'; + featureGroup.innerHTML = ` + + + `; + editFeaturesContainer.appendChild(featureGroup); + }); + + // Add one empty feature field if no features + if (plan.features.length === 0) { + addFeatureField(editFeaturesContainer); + } + } else { + showNotification(data.error || 'Error loading plan', 'error'); + } + }) + .catch(error => { + console.error('Error:', error); + showNotification('Error loading plan', 'error'); + }); +} + +function showDeleteConfirmation(planId, planName) { + document.getElementById('deletePlanName').textContent = planName; + document.getElementById('confirmDeletePlan').setAttribute('data-plan-id', planId); + + const deleteModal = new bootstrap.Modal(document.getElementById('deletePricingPlanModal')); + deleteModal.show(); +} + +function deletePricingPlan(planId) { + fetch(`/api/admin/pricing-plans/${planId}`, { + method: 'DELETE', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification('Pricing plan deleted successfully!', 'success'); + + // Close modal + const deleteModal = bootstrap.Modal.getInstance(document.getElementById('deletePricingPlanModal')); + deleteModal.hide(); + + // Remove from DOM + const planElement = document.querySelector(`[data-plan-id="${planId}"]`); + if (planElement) { + planElement.remove(); + } + + // Check if no plans left + const remainingPlans = document.querySelectorAll('[data-plan-id]'); + if (remainingPlans.length === 0) { + window.location.reload(); + } + } else { + showNotification(data.error || 'Error deleting plan', 'error'); + } + }) + .catch(error => { + console.error('Error:', error); + showNotification('Error deleting plan', 'error'); + }); +} + +function updatePlanStatus(planId, field, value) { + fetch(`/api/admin/pricing-plans/${planId}/status`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value + }, + body: JSON.stringify({ + field: field, + value: value + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification('Plan status updated successfully!', 'success'); + } else { + showNotification(data.error || 'Error updating plan status', 'error'); + // Revert the toggle + const toggle = document.querySelector(`[data-plan-id="${planId}"].${field.replace('is_', 'plan-')}-toggle`); + if (toggle) { + toggle.checked = !value; + } + } + }) + .catch(error => { + console.error('Error:', error); + showNotification('Error updating plan status', 'error'); + // Revert the toggle + const toggle = document.querySelector(`[data-plan-id="${planId}"].${field.replace('is_', 'plan-')}-toggle`); + if (toggle) { + toggle.checked = !value; + } + }); +} + +function showNotification(message, type) { + // Create notification element + const notification = document.createElement('div'); + notification.className = `alert alert-${type === 'success' ? 'success' : 'danger'} alert-dismissible fade show position-fixed`; + notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;'; + notification.innerHTML = ` + ${message} + + `; + + document.body.appendChild(notification); + + // Auto-remove after 5 seconds + setTimeout(() => { + if (notification.parentNode) { + notification.remove(); + } + }, 5000); +} \ No newline at end of file diff --git a/templates/components/pricing_section.html b/templates/components/pricing_section.html index a1fc72e..2f63494 100644 --- a/templates/components/pricing_section.html +++ b/templates/components/pricing_section.html @@ -5,6 +5,72 @@

Simple, Transparent Pricing

Choose the plan that fits your organization's needs

+ + {% set pricing_plans = PricingPlan.get_active_plans() %} + {% if pricing_plans %} +
+ {% for plan in pricing_plans %} +
+ +
+ {% endfor %} +
+ + + {% set has_non_custom_plans = pricing_plans | selectattr('is_custom', 'equalto', false) | list | length > 0 %} + {% if has_non_custom_plans %} +
+ Monthly +
+ + +
+ Annual Save 20% +
+ {% endif %} + + {% else %} +
@@ -107,12 +173,15 @@
Annual Save 20%
+ {% endif %}
\ No newline at end of file diff --git a/templates/settings/settings.html b/templates/settings/settings.html index b5d4c0a..57536b9 100644 --- a/templates/settings/settings.html +++ b/templates/settings/settings.html @@ -9,6 +9,7 @@ {% from "settings/tabs/mails.html" import mails_tab %} {% from "settings/tabs/smtp_settings.html" import smtp_settings_tab %} {% from "settings/tabs/connections.html" import connections_tab %} +{% from "settings/tabs/pricing.html" import pricing_tab %} {% from "settings/components/reset_colors_modal.html" import reset_colors_modal %} {% block title %}Settings - DocuPulse{% endblock %} @@ -82,6 +83,11 @@ Connections + {% endif %} @@ -136,6 +142,11 @@
{{ connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings) }}
+ + +
+ {{ pricing_tab(pricing_plans, csrf_token) }} +
{% endif %} @@ -150,4 +161,7 @@ {% block extra_js %} +{% if is_master and active_tab == 'pricing' %} + +{% endif %} {% endblock %} \ No newline at end of file diff --git a/templates/settings/tabs/pricing.html b/templates/settings/tabs/pricing.html new file mode 100644 index 0000000..f5afabf --- /dev/null +++ b/templates/settings/tabs/pricing.html @@ -0,0 +1,427 @@ +{% macro pricing_tab(pricing_plans, csrf_token) %} +
+
+

Pricing Plans Configuration

+ +
+ + +
+ {% for plan in pricing_plans %} +
+
+
+
{{ plan.name }}
+
+ +
+
+
+
+ Monthly Price: €{{ "%.2f"|format(plan.monthly_price) }}
+ Annual Price: €{{ "%.2f"|format(plan.annual_price) }} +
+ + {% if plan.description %} +
+ Description:
+ {{ plan.description }} +
+ {% endif %} + + +
+ Quotas: +
+
+ + Rooms: {{ plan.format_quota_display('room_quota') }}
+ Conversations: {{ plan.format_quota_display('conversation_quota') }}
+ Storage: {{ plan.format_quota_display('storage_quota_gb') }} +
+
+
+ + Managers: {{ plan.format_quota_display('manager_quota') }}
+ Admins: {{ plan.format_quota_display('admin_quota') }} +
+
+
+
+ +
+ Features: +
    + {% for feature in plan.features %} +
  • {{ feature }}
  • + {% endfor %} +
+
+ +
+ Button: {{ plan.button_text }} → {{ plan.button_url }} +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ {% else %} +
+
+ +
No pricing plans configured
+

Click "Add New Plan" to create your first pricing plan.

+
+
+ {% endfor %} +
+
+ + + + + + + + + +{% endmacro %} \ No newline at end of file