13 Commits

Author SHA1 Message Date
5c2b300c28 Update docker-compose.yml 2025-06-03 08:43:23 +02:00
6f8216cd37 try to get docker working 2025-06-03 08:29:18 +02:00
07e224ccbf Update docker-compose.yml 2025-06-03 08:24:05 +02:00
e6ba4f3d8a Update Dockerfile 2025-06-03 08:21:53 +02:00
33844ddd3e Update Dockerfile 2025-06-03 07:28:18 +02:00
583710763e Update docker-compose.yml 2025-06-03 07:23:46 +02:00
6708d4afaf Update docker-compose.yml 2025-06-03 07:21:45 +02:00
24fbc74c87 Update docker-compose.yml 2025-06-03 07:20:12 +02:00
0f4b21818b Update Dockerfile 2025-06-02 22:12:03 +02:00
6b0012c423 try to fix portainer issue 2025-06-02 21:55:38 +02:00
44fd8433a1 fix start 2025-06-02 21:50:53 +02:00
b493446048 fix preferred view 2025-06-02 21:37:03 +02:00
b72acbf912 fix docker data permissions 2025-06-02 21:30:07 +02:00
211 changed files with 1786 additions and 31327 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
# Exclude everything
*
# Include specific files and directories
!start.sh
!requirements.txt
!app.py
!celery_worker.py
!models.py
!extensions.py
!utils/
!routes/
!templates/
!static/
!migrations/
!uploads/

3
.gitignore vendored
View File

@@ -27,6 +27,3 @@ logs/
# Testing # Testing
coverage/ coverage/
# Python cache
__pycache__/

View File

@@ -4,10 +4,9 @@ FROM python:3.11-slim
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
build-essential \ build-essential \
libpq-dev \ libpq-dev \
postgresql-client \
curl \ curl \
wget \
netcat-traditional \ netcat-traditional \
dos2unix \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Create a non-root user # Create a non-root user
@@ -16,23 +15,24 @@ RUN useradd -m -u 1000 celery
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Copy requirements first to leverage Docker cache # Copy the entire application
COPY requirements.txt . COPY . /app/
# Set up start.sh
RUN chmod +x /app/start.sh && \
chown celery:celery /app/start.sh
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Create necessary directories and set permissions
COPY . . RUN mkdir -p /data/rooms && \
chown -R celery:celery /data && \
# Convert line endings and set permissions chmod -R 755 /data && \
RUN dos2unix /app/entrypoint.sh && \ chown -R celery:celery /app
chmod +x /app/entrypoint.sh && \
mkdir -p /app/uploads/rooms /app/uploads/profile_pics /app/static/uploads && \
chown -R celery:celery /app && \
chmod -R 755 /app/uploads
# Switch to non-root user # Switch to non-root user
USER celery USER celery
# Set entrypoint # Set entrypoint
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/start.sh"]
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

View File

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

View File

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

View File

@@ -10,9 +10,8 @@ DocuPulse is a powerful document management system designed to streamline docume
### Prerequisites ### Prerequisites
- Python 3.11 or higher - Node.js (version 18 or higher)
- PostgreSQL 13 or higher - npm or yarn
- Docker and Docker Compose (for containerized deployment)
### Installation ### Installation
@@ -24,50 +23,18 @@ cd docupulse
2. Install dependencies: 2. Install dependencies:
```bash ```bash
pip install -r requirements.txt npm install
# or
yarn install
``` ```
3. Set up environment variables: 3. Start the development server:
```bash ```bash
# Copy example environment file npm run dev
cp .env.example .env # or
yarn dev
# Set version information for local development
python set_version.py
``` ```
4. Initialize the database:
```bash
flask db upgrade
flask create-admin
```
5. Start the development server:
```bash
python app.py
```
## Version Tracking
DocuPulse uses a database-only approach for version tracking:
- **Environment Variables**: Version information is passed via environment variables (`APP_VERSION`, `GIT_COMMIT`, `GIT_BRANCH`, `DEPLOYED_AT`)
- **Database Storage**: Instance version information is stored in the `instances` table
- **API Endpoint**: Version information is available via `/api/version`
### Setting Version Information
For local development:
```bash
python set_version.py
```
For production deployments, set the following environment variables:
- `APP_VERSION`: Application version/tag
- `GIT_COMMIT`: Git commit hash
- `GIT_BRANCH`: Git branch name
- `DEPLOYED_AT`: Deployment timestamp
## Features ## Features
- Document upload and management - Document upload and management
@@ -75,8 +42,6 @@ For production deployments, set the following environment variables:
- Secure document storage - Secure document storage
- User authentication and authorization - User authentication and authorization
- Document version control - Document version control
- Multi-tenant instance management
- RESTful API
## Contributing ## Contributing

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

119
app.py
View File

@@ -8,22 +8,15 @@ from flask_wtf.csrf import generate_csrf
from routes.room_files import room_files_bp from routes.room_files import room_files_bp
from routes.room_members import room_members_bp from routes.room_members import room_members_bp
from routes.trash import trash_bp from routes.trash import trash_bp
from routes.admin_api import admin_api
from routes.launch_api import launch_api
from tasks import cleanup_trash from tasks import cleanup_trash
import click import click
from utils import timeago from utils import timeago
from extensions import db, login_manager, csrf from extensions import db, login_manager, csrf
from utils.email_templates import create_default_templates from utils.email_templates import create_default_templates
from datetime import datetime from celery_worker import init_celery, celery
from sqlalchemy import text
from utils.asset_utils import get_asset_version
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
print("Environment variables after loading .env:")
print(f"MASTER: {os.getenv('MASTER')}")
print(f"ISMASTER: {os.getenv('ISMASTER')}")
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
@@ -34,12 +27,6 @@ def create_app():
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secure-secret-key-here') app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secure-secret-key-here')
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads') app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads')
app.config['CSS_VERSION'] = os.getenv('CSS_VERSION', '1.0.3') # Add CSS version for cache busting app.config['CSS_VERSION'] = os.getenv('CSS_VERSION', '1.0.3') # Add CSS version for cache busting
app.config['SERVER_NAME'] = os.getenv('SERVER_NAME', '127.0.0.1:5000')
app.config['PREFERRED_URL_SCHEME'] = os.getenv('PREFERRED_URL_SCHEME', 'http')
# Configure request timeouts for long-running operations
app.config['REQUEST_TIMEOUT'] = int(os.getenv('REQUEST_TIMEOUT', '300')) # 5 minutes default
app.config['STACK_DEPLOYMENT_TIMEOUT'] = int(os.getenv('STACK_DEPLOYMENT_TIMEOUT', '300')) # 5 minutes for stack deployment
# Initialize extensions # Initialize extensions
db.init_app(app) db.init_app(app)
@@ -48,6 +35,9 @@ def create_app():
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
csrf.init_app(app) csrf.init_app(app)
# Initialize Celery
init_celery(app)
@app.context_processor @app.context_processor
def inject_csrf_token(): def inject_csrf_token():
return dict(csrf_token=generate_csrf()) return dict(csrf_token=generate_csrf())
@@ -61,20 +51,6 @@ def create_app():
db.session.commit() db.session.commit()
return dict(config=app.config, site_settings=site_settings) return dict(config=app.config, site_settings=site_settings)
@app.context_processor
def inject_unread_notifications():
from flask_login import current_user
from utils import get_unread_count
if current_user.is_authenticated:
unread_count = get_unread_count(current_user.id)
return {'unread_notifications': unread_count}
return {'unread_notifications': 0}
@app.template_filter('asset_version')
def asset_version_filter(filename):
"""Template filter to get version hash for static assets"""
return get_asset_version(filename) or ''
# User loader for Flask-Login # User loader for Flask-Login
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
@@ -84,21 +60,19 @@ def create_app():
@app.route('/health') @app.route('/health')
def health_check(): def health_check():
try: try:
# Check database connection with a timeout # Check database connection
db.session.execute(text('SELECT 1')) db.session.execute('SELECT 1')
db.session.commit() # Check Redis connection
celery.control.inspect().ping()
return jsonify({ return jsonify({
'status': 'healthy', 'status': 'healthy',
'database': 'connected', 'database': 'connected',
'timestamp': datetime.utcnow().isoformat() 'redis': 'connected'
}), 200 }), 200
except Exception as e: except Exception as e:
app.logger.error(f"Health check failed: {str(e)}")
return jsonify({ return jsonify({
'status': 'unhealthy', 'status': 'unhealthy',
'error': str(e), 'error': str(e)
'timestamp': datetime.utcnow().isoformat()
}), 500 }), 500
# Initialize routes # Initialize routes
@@ -107,8 +81,6 @@ def create_app():
app.register_blueprint(room_files_bp, url_prefix='/api/rooms') app.register_blueprint(room_files_bp, url_prefix='/api/rooms')
app.register_blueprint(room_members_bp, url_prefix='/api/rooms') app.register_blueprint(room_members_bp, url_prefix='/api/rooms')
app.register_blueprint(trash_bp, url_prefix='/api/trash') app.register_blueprint(trash_bp, url_prefix='/api/trash')
app.register_blueprint(admin_api, url_prefix='/api/admin')
app.register_blueprint(launch_api, url_prefix='/api/admin')
@app.cli.command("cleanup-trash") @app.cli.command("cleanup-trash")
def cleanup_trash_command(): def cleanup_trash_command():
@@ -116,13 +88,6 @@ def create_app():
cleanup_trash() cleanup_trash()
click.echo("Trash cleanup completed.") click.echo("Trash cleanup completed.")
@app.cli.command("cleanup-tokens")
def cleanup_tokens_command():
"""Clean up expired password reset and setup tokens."""
from tasks import cleanup_expired_tokens
cleanup_expired_tokens()
click.echo("Token cleanup completed.")
@app.cli.command("create-admin") @app.cli.command("create-admin")
def create_admin(): def create_admin():
"""Create the default administrator user.""" """Create the default administrator user."""
@@ -134,20 +99,15 @@ def create_app():
admin = User( admin = User(
username='administrator', username='administrator',
email='administrator@docupulse.com', email='administrator@docupulse.com',
last_name='Administrator', last_name='None',
company='DocuPulse', company='docupulse',
position='System Administrator',
is_admin=True, is_admin=True,
is_active=True, is_active=True
preferred_view='grid'
) )
admin.set_password('changeme') admin.set_password('changeme')
db.session.add(admin) db.session.add(admin)
db.session.commit() db.session.commit()
click.echo("Default administrator user created successfully.") click.echo("Default administrator user created successfully.")
click.echo("Admin credentials:")
click.echo("Email: administrator@docupulse.com")
click.echo("Password: changeme")
# Register custom filters # Register custom filters
app.jinja_env.filters['timeago'] = timeago app.jinja_env.filters['timeago'] = timeago
@@ -157,29 +117,6 @@ def create_app():
try: try:
# Ensure database tables exist # Ensure database tables exist
db.create_all() db.create_all()
# Create admin user first
admin = User.query.filter_by(email='administrator@docupulse.com').first()
if not admin:
admin = User(
username='administrator',
email='administrator@docupulse.com',
last_name='Administrator',
company='DocuPulse',
position='System Administrator',
is_admin=True,
is_active=True,
preferred_view='grid'
)
admin.set_password('changeme')
db.session.add(admin)
db.session.commit()
print("Default administrator user created successfully.")
print("Admin credentials:")
print("Email: administrator@docupulse.com")
print("Password: changeme")
# Then create default templates
create_default_templates() create_default_templates()
except Exception as e: except Exception as e:
print(f"Warning: Could not create default templates: {e}") print(f"Warning: Could not create default templates: {e}")
@@ -188,37 +125,9 @@ def create_app():
app = create_app() app = create_app()
@app.errorhandler(404)
def page_not_found(e):
from flask import render_template
return render_template('common/404.html'), 404
@app.errorhandler(403)
def forbidden(e):
from flask import render_template
return render_template('common/403.html'), 403
@app.errorhandler(401)
def unauthorized(e):
from flask import render_template
return render_template('common/401.html'), 401
@app.errorhandler(400)
def bad_request(e):
from flask import render_template
return render_template('common/400.html'), 400
@app.errorhandler(500)
def internal_server_error(e):
from flask import render_template
import traceback
error_details = f"{str(e)}\n\n{traceback.format_exc()}"
app.logger.error(f"500 error: {error_details}")
return render_template('common/500.html', error=error_details), 500
@app.route('/uploads/profile_pics/<filename>') @app.route('/uploads/profile_pics/<filename>')
def profile_pic(filename): def profile_pic(filename):
return send_from_directory('/app/uploads/profile_pics', filename) return send_from_directory(os.path.join(os.getcwd(), 'uploads', 'profile_pics'), filename)
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True) app.run(debug=True)

51
celery_worker.py Normal file
View File

@@ -0,0 +1,51 @@
from celery import Celery
from flask import current_app
import os
import logging
# Configure logging
logger = logging.getLogger(__name__)
# Get Redis URL from environment variable or use default
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
# Configure Celery
celery = Celery(
'docupulse',
backend=REDIS_URL,
broker=REDIS_URL,
# Add some default configuration
task_serializer='json',
accept_content=['json'],
result_serializer='json',
timezone='UTC',
enable_utc=True,
# Add retry configuration
task_acks_late=True,
task_reject_on_worker_lost=True,
task_default_retry_delay=300, # 5 minutes
task_max_retries=3
)
def init_celery(app):
"""Initialize Celery with Flask app context"""
celery.conf.update(app.config)
class ContextTask(celery.Task):
"""Celery task that runs within Flask app context"""
def __call__(self, *args, **kwargs):
with app.app_context():
return self.run(*args, **kwargs)
def on_failure(self, exc, task_id, args, kwargs, einfo):
"""Handle task failure"""
logger.error(f'Task {task_id} failed: {exc}')
super().on_failure(exc, task_id, args, kwargs, einfo)
def on_retry(self, exc, task_id, args, kwargs, einfo):
"""Handle task retry"""
logger.warning(f'Task {task_id} is being retried: {exc}')
super().on_retry(exc, task_id, args, kwargs, einfo)
celery.Task = ContextTask
return celery

View File

@@ -1,68 +1,91 @@
version: '3.8'
networks:
docupulse_network:
driver: bridge
services: services:
web: web:
build: build: .
# context: . command: gunicorn --bind 0.0.0.0:5000 app:app
# dockerfile: Dockerfile
context: https://git.kobeamerijckx.com/Kobe/docupulse.git
dockerfile: Dockerfile
ports: ports:
- "${PORT:-10335}:5000" - "10335:5000"
environment: environment:
- FLASK_APP=app.py - FLASK_APP=app.py
- FLASK_ENV=production - FLASK_ENV=production
- DATABASE_URL=postgresql://docupulse_${PORT:-10335}:docupulse_${PORT:-10335}@db:5432/docupulse_${PORT:-10335} - DATABASE_URL=postgresql://postgres:postgres@db:5432/docupulse
- POSTGRES_USER=docupulse_${PORT:-10335} - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=docupulse_${PORT:-10335} - POSTGRES_PASSWORD=postgres
- POSTGRES_DB=docupulse_${PORT:-10335} - POSTGRES_DB=docupulse
- MASTER=${ISMASTER:-false} - REDIS_URL=redis://redis:6379/0
- APP_VERSION=${APP_VERSION:-unknown}
- GIT_COMMIT=${GIT_COMMIT:-unknown}
- GIT_BRANCH=${GIT_BRANCH:-unknown}
- DEPLOYED_AT=${DEPLOYED_AT:-unknown}
volumes: volumes:
- docupulse_uploads:/app/uploads - uploads:/data
depends_on: depends_on:
- db db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/health"]
interval: 60s interval: 30s
timeout: 30s timeout: 10s
retries: 3 retries: 3
start_period: 120s
deploy: deploy:
resources: resources:
limits: limits:
cpus: '1' cpus: '1'
memory: 1G memory: 1G
networks:
- docupulse_network
db: db:
image: postgres:13 image: postgres:13
environment: environment:
- POSTGRES_USER=docupulse_${PORT:-10335} - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=docupulse_${PORT:-10335} - POSTGRES_PASSWORD=postgres
- POSTGRES_DB=docupulse_${PORT:-10335} - POSTGRES_DB=docupulse
volumes: volumes:
- docupulse_postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U docupulse_${PORT:-10335}"] test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
networks:
- docupulse_network redis:
image: redis:7
ports:
- "26379:6379"
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
celery_worker:
build:
context: https://git.kobeamerijckx.com/Kobe/docupulse.git
dockerfile: Dockerfile
command: celery -A celery_worker.celery worker --loglevel=info
volumes:
- .:/app
environment:
- FLASK_APP=app.py
- FLASK_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@db:5432/docupulse
- REDIS_URL=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "celery", "-A", "celery_worker.celery", "inspect", "ping"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
volumes: volumes:
docupulse_postgres_data: postgres_data:
name: docupulse_${PORT:-10335}_postgres_data uploads:
docupulse_uploads:
name: docupulse_${PORT:-10335}_uploads

View File

@@ -6,29 +6,21 @@ echo "POSTGRES_USER: $POSTGRES_USER"
echo "POSTGRES_PASSWORD: $POSTGRES_PASSWORD" echo "POSTGRES_PASSWORD: $POSTGRES_PASSWORD"
echo "POSTGRES_DB: $POSTGRES_DB" echo "POSTGRES_DB: $POSTGRES_DB"
echo "DATABASE_URL: $DATABASE_URL" echo "DATABASE_URL: $DATABASE_URL"
echo "REDIS_URL: $REDIS_URL"
# Function to wait for database # Wait for the database to be ready
wait_for_db() { echo "Waiting for database to be ready..."
echo "Waiting for database..." while ! nc -z db 5432; do
while ! nc -z db 5432; do sleep 0.1
sleep 1 done
done echo "Database is ready!"
echo "Database is ready!"
}
# Function to create database if it doesn't exist # Wait for Redis to be ready
create_database() { echo "Waiting for Redis to be ready..."
echo "Creating database if it doesn't exist..." while ! nc -z redis 6379; do
PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -tc "SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB'" | grep -q 1 || \ sleep 0.1
PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -c "CREATE DATABASE $POSTGRES_DB" done
echo "Database check/creation complete!" echo "Redis is ready!"
}
# Wait for database to be ready
wait_for_db
# Create database if it doesn't exist
create_database
# Wait for PostgreSQL to be ready to accept connections # Wait for PostgreSQL to be ready to accept connections
echo "Waiting for PostgreSQL to accept connections..." echo "Waiting for PostgreSQL to accept connections..."
@@ -38,112 +30,60 @@ until PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB
done done
echo "PostgreSQL is up - executing command" echo "PostgreSQL is up - executing command"
# Run all initialization in a single Python script to avoid multiple Flask instances # Clean up existing migrations and initialize fresh
echo "Running initialization..." echo "Cleaning up and initializing fresh migrations..."
rm -rf migrations/versions/*
flask db init
flask db migrate -m "Initial migration"
flask db upgrade
# Create events table
echo "Creating events table..."
python3 -c " python3 -c "
import sys from migrations.add_events_table import upgrade
from app import create_app from app import create_app
from models import SiteSettings, db, User
from utils.email_templates import create_default_templates
def log_error(message, error=None):
print(f'ERROR: {message}', file=sys.stderr)
if error:
print(f'Error details: {str(error)}', file=sys.stderr)
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
try: try:
# Run migrations
print('Running database migrations...')
from flask_migrate import upgrade
upgrade() upgrade()
print('Database migrations completed successfully') print('Events table created successfully')
except Exception as e:
print(f'Error creating events table: {e}')
"
# Create default site settings # Create notifs table
print('Creating default site settings...') echo "Creating notifs table..."
python3 -c "
from migrations.add_notifs_table import upgrade
from app import create_app
app = create_app()
with app.app_context():
try:
upgrade()
print('Notifs table created successfully')
except Exception as e:
print(f'Error creating notifs table: {e}')
"
# Create default site settings if they don't exist
echo "Creating default site settings..."
python3 -c "
from app import create_app
from models import SiteSettings, db
app = create_app()
with app.app_context():
try: try:
settings = SiteSettings.get_settings() settings = SiteSettings.get_settings()
print('Default site settings created successfully') print('Default site settings created successfully')
except Exception as e: except Exception as e:
log_error('Error creating site settings', e) print(f'Error creating site settings: {e}')
"
# Create admin user if it doesn't exist # Initialize admin user
print('Creating admin user...') echo "Initializing admin user..."
try: python3 -c "
# Check for admin user by both username and email to avoid constraint violations from init_admin import init_admin
admin_by_username = User.query.filter_by(username='administrator').first() init_admin()
admin_by_email = User.query.filter_by(email='administrator@docupulse.com').first()
if admin_by_username and admin_by_email and admin_by_username.id == admin_by_email.id:
print('Admin user already exists (found by both username and email).')
print('Admin credentials:')
print('Email: administrator@docupulse.com')
print('Password: changeme')
elif admin_by_username or admin_by_email:
print('WARNING: Found partial admin user data:')
if admin_by_username:
print(f' - Found user with username "administrator" (ID: {admin_by_username.id})')
if admin_by_email:
print(f' - Found user with email "administrator@docupulse.com" (ID: {admin_by_email.id})')
print('Admin credentials:')
print('Email: administrator@docupulse.com')
print('Password: changeme')
else:
print('Admin user not found, creating new admin user...')
admin = User(
username='administrator',
email='administrator@docupulse.com',
last_name='Administrator',
company='DocuPulse',
position='System Administrator',
is_admin=True,
is_active=True,
preferred_view='grid'
)
admin.set_password('changeme')
print('Admin user object created, attempting to add to database...')
db.session.add(admin)
try:
db.session.commit()
print('Default administrator user created successfully.')
print('Admin credentials:')
print('Email: administrator@docupulse.com')
print('Password: changeme')
except Exception as commit_error:
db.session.rollback()
if 'duplicate key value violates unique constraint' in str(commit_error):
print('WARNING: Admin user creation failed due to duplicate key constraint.')
print('This might indicate a race condition or the user was created by another process.')
print('Checking for existing admin user again...')
# Check again after the failed commit
admin_by_username = User.query.filter_by(username='administrator').first()
admin_by_email = User.query.filter_by(email='administrator@docupulse.com').first()
if admin_by_username or admin_by_email:
print('Admin user now exists (likely created by another process).')
print('Admin credentials:')
print('Email: administrator@docupulse.com')
print('Password: changeme')
else:
log_error('Admin user creation failed and user still not found', commit_error)
raise
else:
log_error('Failed to commit admin user creation', commit_error)
raise
except Exception as e:
log_error('Error during admin user creation/check', e)
raise
# Create default templates
print('Creating default templates...')
try:
create_default_templates()
print('Default templates created successfully')
except Exception as e:
log_error('Error creating default templates', e)
except Exception as e:
log_error('Fatal error during initialization', e)
sys.exit(1)
" "
# Start the application # Start the application

View File

@@ -1,5 +1,5 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField, SelectMultipleField, SelectField from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField, SelectMultipleField
from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError
from models import User from models import User
from flask_login import current_user from flask_login import current_user
@@ -13,11 +13,7 @@ class UserForm(FlaskForm):
company = StringField('Company (Optional)', validators=[Optional(), Length(max=100)]) company = StringField('Company (Optional)', validators=[Optional(), Length(max=100)])
position = StringField('Position (Optional)', validators=[Optional(), Length(max=100)]) position = StringField('Position (Optional)', validators=[Optional(), Length(max=100)])
notes = TextAreaField('Notes (Optional)', validators=[Optional()]) notes = TextAreaField('Notes (Optional)', validators=[Optional()])
role = SelectField('Role', choices=[ is_admin = BooleanField('Admin Role', default=False)
('user', 'Standard User'),
('manager', 'Manager'),
('admin', 'Administrator')
], validators=[DataRequired()])
new_password = PasswordField('New Password (Optional)') new_password = PasswordField('New Password (Optional)')
confirm_password = PasswordField('Confirm Password (Optional)') confirm_password = PasswordField('Confirm Password (Optional)')
profile_picture = FileField('Profile Picture (Optional)', validators=[FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!')]) profile_picture = FileField('Profile Picture (Optional)', validators=[FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!')])
@@ -34,11 +30,6 @@ class UserForm(FlaskForm):
if total_admins <= 1: if total_admins <= 1:
raise ValidationError('There must be at least one admin user in the system.') raise ValidationError('There must be at least one admin user in the system.')
def validate_is_manager(self, field):
# Prevent setting both admin and manager roles
if field.data and self.is_admin.data:
raise ValidationError('A user cannot be both an admin and a manager.')
def validate(self, extra_validators=None): def validate(self, extra_validators=None):
rv = super().validate(extra_validators=extra_validators) rv = super().validate(extra_validators=extra_validators)
if not rv: if not rv:

View File

@@ -1,170 +0,0 @@
#!/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()

View File

@@ -0,0 +1,61 @@
import os
import sys
from pathlib import Path
# Add the parent directory to Python path so we can import from root
sys.path.append(str(Path(__file__).parent.parent))
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from extensions import db
from sqlalchemy import text
def upgrade():
# Create events table
with db.engine.connect() as conn:
conn.execute(text('''
CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
user_id INTEGER NOT NULL REFERENCES "user" (id),
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
details JSONB,
ip_address VARCHAR(45),
user_agent VARCHAR(255)
);
-- Create index on event_type for faster filtering
CREATE INDEX IF NOT EXISTS idx_events_event_type ON events(event_type);
-- Create index on timestamp for faster date-based queries
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
-- Create index on user_id for faster user-based queries
CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id);
'''))
conn.commit()
def downgrade():
# Drop events table and its indexes
with db.engine.connect() as conn:
conn.execute(text('''
DROP INDEX IF EXISTS idx_events_event_type;
DROP INDEX IF EXISTS idx_events_timestamp;
DROP INDEX IF EXISTS idx_events_user_id;
DROP TABLE IF EXISTS events;
'''))
conn.commit()
if __name__ == '__main__':
app = Flask(__name__)
# Use the same database configuration as in app.py
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'postgresql://postgres:1253@localhost:5432/docupulse')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
print("Connecting to database...")
db.init_app(app)
with app.app_context():
upgrade()

View File

@@ -0,0 +1,61 @@
import os
import sys
from pathlib import Path
# Add the parent directory to Python path so we can import from root
sys.path.append(str(Path(__file__).parent.parent))
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from extensions import db
from sqlalchemy import text
def upgrade():
# Create notifs table
with db.engine.connect() as conn:
conn.execute(text('''
CREATE TABLE IF NOT EXISTS notifs (
id SERIAL PRIMARY KEY,
notif_type VARCHAR(50) NOT NULL,
user_id INTEGER NOT NULL REFERENCES "user" (id),
sender_id INTEGER REFERENCES "user" (id),
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
read BOOLEAN NOT NULL DEFAULT FALSE,
details JSONB
);
-- Create indexes for faster queries
CREATE INDEX IF NOT EXISTS idx_notifs_notif_type ON notifs(notif_type);
CREATE INDEX IF NOT EXISTS idx_notifs_timestamp ON notifs(timestamp);
CREATE INDEX IF NOT EXISTS idx_notifs_user_id ON notifs(user_id);
CREATE INDEX IF NOT EXISTS idx_notifs_sender_id ON notifs(sender_id);
CREATE INDEX IF NOT EXISTS idx_notifs_read ON notifs(read);
'''))
conn.commit()
def downgrade():
# Drop notifs table and its indexes
with db.engine.connect() as conn:
conn.execute(text('''
DROP INDEX IF EXISTS idx_notifs_notif_type;
DROP INDEX IF EXISTS idx_notifs_timestamp;
DROP INDEX IF EXISTS idx_notifs_user_id;
DROP INDEX IF EXISTS idx_notifs_sender_id;
DROP INDEX IF EXISTS idx_notifs_read;
DROP TABLE IF EXISTS notifs;
'''))
conn.commit()
if __name__ == '__main__':
app = Flask(__name__)
# Use the same database configuration as in app.py
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'postgresql://postgres:1253@localhost:5432/docupulse')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
print("Connecting to database...")
db.init_app(app)
with app.app_context():
upgrade()

View File

@@ -1,24 +0,0 @@
"""merge heads
Revision ID: 4ee23cb29001
Revises: 72ab6c4c6a5f, add_status_details
Create Date: 2025-06-09 10:04:48.708415
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4ee23cb29001'
down_revision = ('72ab6c4c6a5f', 'add_status_details')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -1,32 +0,0 @@
"""merge heads
Revision ID: 72ab6c4c6a5f
Revises: 0a8006bd1732, add_docupulse_settings, add_manager_role, make_events_user_id_nullable
Create Date: 2025-06-05 14:21:46.046125
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '72ab6c4c6a5f'
down_revision = ('0a8006bd1732', 'add_docupulse_settings', 'add_manager_role', 'make_events_user_id_nullable')
branch_labels = None
depends_on = None
def upgrade():
# Ensure is_manager column exists
conn = op.get_bind()
inspector = inspect(conn)
columns = [col['name'] for col in inspector.get_columns('user')]
with op.batch_alter_table('user', schema=None) as batch_op:
if 'is_manager' not in columns:
batch_op.add_column(sa.Column('is_manager', sa.Boolean(), nullable=True, server_default='false'))
def downgrade():
pass

View File

@@ -1,24 +0,0 @@
"""merge heads
Revision ID: 761908f0cacf
Revises: 4ee23cb29001, add_connection_token
Create Date: 2025-06-09 13:57:17.650231
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '761908f0cacf'
down_revision = ('4ee23cb29001', 'add_connection_token')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -1,46 +0,0 @@
"""add portainer stack fields to instances
Revision ID: 9206bf87bb8e
Revises: add_quota_fields
Create Date: 2025-06-24 14:02:17.375785
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = '9206bf87bb8e'
down_revision = 'add_quota_fields'
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 = 'instances'
AND column_name IN ('portainer_stack_id', 'portainer_stack_name')
"""))
existing_columns = [row[0] for row in result.fetchall()]
# Add portainer stack columns if they don't exist
with op.batch_alter_table('instances', schema=None) as batch_op:
if 'portainer_stack_id' not in existing_columns:
batch_op.add_column(sa.Column('portainer_stack_id', sa.String(length=100), nullable=True))
if 'portainer_stack_name' not in existing_columns:
batch_op.add_column(sa.Column('portainer_stack_name', sa.String(length=100), nullable=True))
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('instances', schema=None) as batch_op:
batch_op.drop_column('portainer_stack_name')
batch_op.drop_column('portainer_stack_id')
# ### end Alembic commands ###

View File

@@ -1,24 +0,0 @@
"""merge heads
Revision ID: a1fd98e6630d
Revises: 761908f0cacf, add_password_reset_tokens
Create Date: 2025-06-23 09:12:50.264151
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a1fd98e6630d'
down_revision = ('761908f0cacf', 'add_password_reset_tokens')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -1,31 +0,0 @@
"""add connection_token column
Revision ID: add_connection_token
Revises: fix_updated_at_trigger
Create Date: 2024-03-19 13:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'add_connection_token'
down_revision = 'fix_updated_at_trigger'
branch_labels = None
depends_on = None
def upgrade():
# Get the inspector
inspector = inspect(op.get_bind())
# Check if the column exists
columns = [col['name'] for col in inspector.get_columns('instances')]
if 'connection_token' not in columns:
op.add_column('instances', sa.Column('connection_token', sa.String(64), nullable=True, unique=True))
def downgrade():
op.drop_column('instances', 'connection_token')

View File

@@ -1,56 +0,0 @@
"""add docupulse settings table
Revision ID: add_docupulse_settings
Revises: add_notifs_table
Create Date: 2024-03-19 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'add_docupulse_settings'
down_revision = 'add_notifs_table'
branch_labels = None
depends_on = None
def upgrade():
# Check if table exists
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'docupulse_settings' in inspector.get_table_names():
# Table exists, alter the max_storage column
op.alter_column('docupulse_settings', 'max_storage',
existing_type=sa.Integer(),
type_=sa.BigInteger(),
existing_nullable=False,
server_default='10737418240')
# Check if we need to insert default data
result = conn.execute(text("SELECT COUNT(*) FROM docupulse_settings")).scalar()
if result == 0:
conn.execute(text("""
INSERT INTO docupulse_settings (id, max_rooms, max_conversations, max_storage, updated_at)
VALUES (1, 10, 10, 10737418240, CURRENT_TIMESTAMP)
"""))
else:
# Create new table
op.create_table('docupulse_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('max_rooms', sa.Integer(), nullable=False, server_default='10'),
sa.Column('max_conversations', sa.Integer(), nullable=False, server_default='10'),
sa.Column('max_storage', sa.BigInteger(), nullable=False, server_default='10737418240'), # 10GB in bytes
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
# Insert default settings
op.execute(text("""
INSERT INTO docupulse_settings (id, max_rooms, max_conversations, max_storage, updated_at)
VALUES (1, 10, 10, 10737418240, CURRENT_TIMESTAMP)
"""))
def downgrade():
op.drop_table('docupulse_settings')

View File

@@ -1,42 +0,0 @@
"""create events table
Revision ID: add_events_table
Revises: f18735338888
Create Date: 2024-03-19 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'add_events_table'
down_revision = 'f18735338888'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
inspector = inspect(conn)
tables = inspector.get_table_names()
if 'events' not in tables:
op.create_table(
'events',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('event_type', sa.String(50), nullable=False),
sa.Column('user_id', sa.Integer, sa.ForeignKey('user.id'), nullable=True),
sa.Column('timestamp', sa.DateTime, nullable=False),
sa.Column('details', sa.JSON),
sa.Column('ip_address', sa.String(45)),
sa.Column('user_agent', sa.String(255)),
)
op.create_index('idx_events_event_type', 'events', ['event_type'])
op.create_index('idx_events_timestamp', 'events', ['timestamp'])
op.create_index('idx_events_user_id', 'events', ['user_id'])
def downgrade():
op.drop_index('idx_events_event_type', table_name='events')
op.drop_index('idx_events_timestamp', table_name='events')
op.drop_index('idx_events_user_id', table_name='events')
op.drop_table('events')

View File

@@ -1,56 +0,0 @@
"""add help articles table
Revision ID: add_help_articles_table
Revises: c94c2b2b9f2e
Create Date: 2024-12-19 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'add_help_articles_table'
down_revision = 'c94c2b2b9f2e'
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 = 'help_articles'
);
"""))
exists = result.scalar()
if not exists:
op.create_table('help_articles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('category', sa.String(length=50), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
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.Column('is_published', sa.Boolean(), nullable=True, server_default='true'),
sa.Column('order_index', sa.Integer(), nullable=True, server_default='0'),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for better performance
op.create_index('idx_help_articles_category', 'help_articles', ['category'])
op.create_index('idx_help_articles_published', 'help_articles', ['is_published'])
op.create_index('idx_help_articles_order', 'help_articles', ['order_index'])
op.create_index('idx_help_articles_created_at', 'help_articles', ['created_at'])
def downgrade():
op.drop_index('idx_help_articles_category', table_name='help_articles')
op.drop_index('idx_help_articles_published', table_name='help_articles')
op.drop_index('idx_help_articles_order', table_name='help_articles')
op.drop_index('idx_help_articles_created_at', table_name='help_articles')
op.drop_table('help_articles')

View File

@@ -1,69 +0,0 @@
"""add instances table
Revision ID: add_instances_table
Revises:
Create Date: 2024-03-19 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'add_instances_table'
down_revision = None
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 = 'instances'
);
"""))
exists = result.scalar()
if not exists:
op.create_table('instances',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('company', sa.String(length=255), nullable=False),
sa.Column('rooms_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('conversations_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('data_size', sa.String(length=50), nullable=False, server_default='0 MB'),
sa.Column('payment_plan', sa.String(length=50), nullable=False, server_default='free'),
sa.Column('main_url', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False, server_default='inactive'),
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')),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
sa.UniqueConstraint('main_url')
)
# Create a trigger to automatically update the updated_at column
op.execute("""
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
""")
op.execute("""
CREATE TRIGGER update_instances_updated_at
BEFORE UPDATE ON instances
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
""")
def downgrade():
op.execute("DROP TRIGGER IF EXISTS update_instances_updated_at ON instances")
op.execute("DROP FUNCTION IF EXISTS update_updated_at_column()")
op.drop_table('instances')

View File

@@ -1,38 +0,0 @@
"""Add manager role
Revision ID: add_manager_role
Revises: 25da158dd705
Create Date: 2024-03-20 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'add_manager_role'
down_revision = '25da158dd705'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
conn = op.get_bind()
inspector = inspect(conn)
columns = [col['name'] for col in inspector.get_columns('user')]
with op.batch_alter_table('user', schema=None) as batch_op:
if 'is_manager' not in columns:
batch_op.add_column(sa.Column('is_manager', sa.Boolean(), nullable=True, server_default='false'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('is_manager')
# ### end Alembic commands ###

View File

@@ -1,46 +0,0 @@
"""create notifs table
Revision ID: add_notifs_table
Revises: add_events_table
Create Date: 2024-03-19 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'add_notifs_table'
down_revision = 'add_events_table'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
inspector = inspect(conn)
tables = inspector.get_table_names()
if 'notifs' not in tables:
op.create_table(
'notifs',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('notif_type', sa.String(50), nullable=False),
sa.Column('user_id', sa.Integer, sa.ForeignKey('user.id'), nullable=False),
sa.Column('sender_id', sa.Integer, sa.ForeignKey('user.id'), nullable=True),
sa.Column('timestamp', sa.DateTime, nullable=False),
sa.Column('read', sa.Boolean, nullable=False, default=False),
sa.Column('details', sa.JSON),
)
op.create_index('idx_notifs_notif_type', 'notifs', ['notif_type'])
op.create_index('idx_notifs_timestamp', 'notifs', ['timestamp'])
op.create_index('idx_notifs_user_id', 'notifs', ['user_id'])
op.create_index('idx_notifs_sender_id', 'notifs', ['sender_id'])
op.create_index('idx_notifs_read', 'notifs', ['read'])
def downgrade():
op.drop_index('idx_notifs_notif_type', table_name='notifs')
op.drop_index('idx_notifs_timestamp', table_name='notifs')
op.drop_index('idx_notifs_user_id', table_name='notifs')
op.drop_index('idx_notifs_sender_id', table_name='notifs')
op.drop_index('idx_notifs_read', table_name='notifs')
op.drop_table('notifs')

View File

@@ -1,44 +0,0 @@
"""Add password reset tokens table
Revision ID: add_password_reset_tokens
Revises: be1f7bdd10e1
Create Date: 2024-01-01 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'add_password_reset_tokens'
down_revision = 'be1f7bdd10e1'
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 = 'password_reset_tokens'
);
"""))
exists = result.scalar()
if not exists:
# Create password_reset_tokens table
op.create_table('password_reset_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(length=100), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('used', sa.Boolean(), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token')
)
def downgrade():
# Drop password_reset_tokens table
op.drop_table('password_reset_tokens')

View File

@@ -1,62 +0,0 @@
"""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')

View File

@@ -1,55 +0,0 @@
"""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')

View File

@@ -1,36 +0,0 @@
"""add status_details column
Revision ID: add_status_details
Revises: add_instances_table
Create Date: 2024-03-19 11:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'add_status_details'
down_revision = 'add_instances_table'
branch_labels = None
depends_on = None
def upgrade():
# Check if column exists before adding
conn = op.get_bind()
result = conn.execute(text("""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'instances' AND column_name = 'status_details'
);
"""))
exists = result.scalar()
if not exists:
op.add_column('instances', sa.Column('status_details', sa.Text(), nullable=True))
def downgrade():
op.drop_column('instances', 'status_details')

View File

@@ -1,87 +0,0 @@
"""add version tracking fields to instances
Revision ID: c94c2b2b9f2e
Revises: a1fd98e6630d
Create Date: 2025-06-23 09:15:13.092801
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'c94c2b2b9f2e'
down_revision = 'a1fd98e6630d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('email_template')
op.drop_table('notification')
# Check if columns already exist before adding them
connection = op.get_bind()
inspector = sa.inspect(connection)
existing_columns = [col['name'] for col in inspector.get_columns('instances')]
with op.batch_alter_table('instances', schema=None) as batch_op:
if 'deployed_version' not in existing_columns:
batch_op.add_column(sa.Column('deployed_version', sa.String(length=50), nullable=True))
if 'deployed_branch' not in existing_columns:
batch_op.add_column(sa.Column('deployed_branch', sa.String(length=100), nullable=True))
if 'latest_version' not in existing_columns:
batch_op.add_column(sa.Column('latest_version', sa.String(length=50), nullable=True))
if 'version_checked_at' not in existing_columns:
batch_op.add_column(sa.Column('version_checked_at', sa.DateTime(), nullable=True))
with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('fk_room_file_deleted_by_user'), type_='foreignkey')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.create_foreign_key(batch_op.f('fk_room_file_deleted_by_user'), 'user', ['deleted_by'], ['id'])
# Check if columns exist before dropping them
connection = op.get_bind()
inspector = sa.inspect(connection)
existing_columns = [col['name'] for col in inspector.get_columns('instances')]
with op.batch_alter_table('instances', schema=None) as batch_op:
if 'version_checked_at' in existing_columns:
batch_op.drop_column('version_checked_at')
if 'latest_version' in existing_columns:
batch_op.drop_column('latest_version')
if 'deployed_branch' in existing_columns:
batch_op.drop_column('deployed_branch')
if 'deployed_version' in existing_columns:
batch_op.drop_column('deployed_version')
op.create_table('notification',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('title', sa.VARCHAR(length=200), autoincrement=False, nullable=False),
sa.Column('message', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('read', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('notification_user_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('notification_pkey'))
)
op.create_table('email_template',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('subject', sa.VARCHAR(length=200), autoincrement=False, nullable=False),
sa.Column('body', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('email_template_pkey')),
sa.UniqueConstraint('name', name=op.f('email_template_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
# ### end Alembic commands ###

View File

@@ -1,45 +0,0 @@
"""fix updated_at trigger
Revision ID: fix_updated_at_trigger
Revises: add_status_details
Create Date: 2024-03-19 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'fix_updated_at_trigger'
down_revision = 'add_status_details'
branch_labels = None
depends_on = None
def upgrade():
# Drop the existing trigger if it exists
op.execute("DROP TRIGGER IF EXISTS update_instances_updated_at ON instances")
op.execute("DROP FUNCTION IF EXISTS update_updated_at_column()")
# Create the trigger function
op.execute("""
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
""")
# Create the trigger
op.execute("""
CREATE TRIGGER update_instances_updated_at
BEFORE UPDATE ON instances
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
""")
def downgrade():
# Drop the trigger and function
op.execute("DROP TRIGGER IF EXISTS update_instances_updated_at ON instances")
op.execute("DROP FUNCTION IF EXISTS update_updated_at_column()")

View File

@@ -1,27 +0,0 @@
"""make events user_id nullable
Revision ID: make_events_user_id_nullable
Revises: f18735338888
Create Date: 2024-03-19 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'make_events_user_id_nullable'
down_revision = 'f18735338888' # This should be the latest migration
branch_labels = None
depends_on = None
def upgrade():
# Make user_id nullable in events table
op.alter_column('events', 'user_id',
existing_type=sa.Integer(),
nullable=True)
def downgrade():
# Make user_id non-nullable again
op.alter_column('events', 'user_id',
existing_type=sa.Integer(),
nullable=False)

364
models.py
View File

@@ -22,11 +22,10 @@ conversation_members = db.Table('conversation_members',
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False) username = db.Column(db.String(150), unique=True, nullable=False)
last_name = db.Column(db.String(150), nullable=False, default='--') last_name = db.Column(db.String(150), nullable=False, default='(You)')
email = db.Column(db.String(150), unique=True, nullable=False) email = db.Column(db.String(150), unique=True, nullable=False)
password_hash = db.Column(db.String(256)) password_hash = db.Column(db.String(256))
is_admin = db.Column(db.Boolean, default=False) is_admin = db.Column(db.Boolean, default=False)
is_manager = db.Column(db.Boolean, default=False) # New field for manager role
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
phone = db.Column(db.String(20)) phone = db.Column(db.String(20))
company = db.Column(db.String(100)) company = db.Column(db.String(100))
@@ -35,11 +34,7 @@ class User(UserMixin, db.Model):
is_active = db.Column(db.Boolean, default=True) is_active = db.Column(db.Boolean, default=True)
profile_picture = db.Column(db.String(255)) profile_picture = db.Column(db.String(255))
preferred_view = db.Column(db.String(10), default='grid', nullable=False) # 'grid' or 'list' preferred_view = db.Column(db.String(10), default='grid', nullable=False) # 'grid' or 'list'
room_permissions = relationship( room_permissions = relationship('RoomMemberPermission', back_populates='user')
'RoomMemberPermission',
back_populates='user',
cascade='all, delete-orphan'
)
def set_password(self, password): def set_password(self, password):
self.password_hash = generate_password_hash(password) self.password_hash = generate_password_hash(password)
@@ -55,10 +50,10 @@ class Room(db.Model):
name = db.Column(db.String(100), nullable=False) name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships # Relationships
creator = db.relationship('User', backref=db.backref('created_rooms', cascade='all, delete-orphan'), foreign_keys=[created_by]) creator = db.relationship('User', backref='created_rooms', foreign_keys=[created_by])
members = db.relationship('User', secondary=room_members, backref=db.backref('rooms', lazy='dynamic')) members = db.relationship('User', secondary=room_members, backref=db.backref('rooms', lazy='dynamic'))
member_permissions = relationship('RoomMemberPermission', back_populates='room', cascade='all, delete-orphan') member_permissions = relationship('RoomMemberPermission', back_populates='room', cascade='all, delete-orphan')
files = db.relationship('RoomFile', back_populates='room', cascade='all, delete-orphan') files = db.relationship('RoomFile', back_populates='room', cascade='all, delete-orphan')
@@ -70,7 +65,7 @@ class Room(db.Model):
class RoomMemberPermission(db.Model): class RoomMemberPermission(db.Model):
__tablename__ = 'room_member_permissions' __tablename__ = 'room_member_permissions'
room_id = db.Column(db.Integer, db.ForeignKey('room.id'), primary_key=True) room_id = db.Column(db.Integer, db.ForeignKey('room.id'), primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
can_view = db.Column(db.Boolean, default=True, nullable=False) can_view = db.Column(db.Boolean, default=True, nullable=False)
can_download = db.Column(db.Boolean, default=False, nullable=False) can_download = db.Column(db.Boolean, default=False, nullable=False)
can_upload = db.Column(db.Boolean, default=False, nullable=False) can_upload = db.Column(db.Boolean, default=False, nullable=False)
@@ -91,13 +86,13 @@ class RoomFile(db.Model):
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder' type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
size = db.Column(db.Integer) # in bytes, null for folders size = db.Column(db.Integer) # in bytes, null for folders
modified = db.Column(db.Float) # timestamp modified = db.Column(db.Float) # timestamp
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE')) uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'))
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
deleted = db.Column(db.Boolean, default=False) # New field for deleted status deleted = db.Column(db.Boolean, default=False) # New field for deleted status
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE')) deleted_by = db.Column(db.Integer, db.ForeignKey('user.id')) # New field for tracking who deleted the file
deleted_at = db.Column(db.DateTime) # New field for tracking when the file was deleted deleted_at = db.Column(db.DateTime) # New field for tracking when the file was deleted
uploader = db.relationship('User', backref=db.backref('uploaded_files', cascade='all, delete-orphan'), foreign_keys=[uploaded_by]) uploader = db.relationship('User', backref='uploaded_files', foreign_keys=[uploaded_by])
deleter = db.relationship('User', backref=db.backref('deleted_room_files', cascade='all, delete-orphan'), foreign_keys=[deleted_by]) deleter = db.relationship('User', backref='deleted_room_files', foreign_keys=[deleted_by])
room = db.relationship('Room', back_populates='files') room = db.relationship('Room', back_populates='files')
starred_by = db.relationship('User', secondary='user_starred_file', backref='starred_files') starred_by = db.relationship('User', secondary='user_starred_file', backref='starred_files')
@@ -107,7 +102,7 @@ class RoomFile(db.Model):
class UserStarredFile(db.Model): class UserStarredFile(db.Model):
__tablename__ = 'user_starred_file' __tablename__ = 'user_starred_file'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
file_id = db.Column(db.Integer, db.ForeignKey('room_file.id'), nullable=False) file_id = db.Column(db.Integer, db.ForeignKey('room_file.id'), nullable=False)
starred_at = db.Column(db.DateTime, default=datetime.utcnow) starred_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -128,13 +123,13 @@ class TrashedFile(db.Model):
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder' type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
size = db.Column(db.Integer) # in bytes, null for folders size = db.Column(db.Integer) # in bytes, null for folders
modified = db.Column(db.Float) # timestamp modified = db.Column(db.Float) # timestamp
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE')) uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'))
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) deleted_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
deleted_at = db.Column(db.DateTime, default=datetime.utcnow) deleted_at = db.Column(db.DateTime, default=datetime.utcnow)
room = db.relationship('Room', backref='trashed_files') room = db.relationship('Room', backref='trashed_files')
uploader = db.relationship('User', foreign_keys=[uploaded_by], backref=db.backref('uploaded_trashed_files', cascade='all, delete-orphan')) uploader = db.relationship('User', foreign_keys=[uploaded_by], backref='uploaded_trashed_files')
deleter = db.relationship('User', foreign_keys=[deleted_by], backref=db.backref('deleted_trashed_files', cascade='all, delete-orphan')) deleter = db.relationship('User', foreign_keys=[deleted_by], backref='deleted_trashed_files') # Changed from deleted_files to deleted_trashed_files
def __repr__(self): def __repr__(self):
return f'<TrashedFile {self.name} ({self.type}) from {self.original_path}>' return f'<TrashedFile {self.name} ({self.type}) from {self.original_path}>'
@@ -166,65 +161,6 @@ class SiteSettings(db.Model):
db.session.commit() db.session.commit()
return settings return settings
class DocuPulseSettings(db.Model):
__tablename__ = 'docupulse_settings'
id = db.Column(db.Integer, primary_key=True)
max_rooms = db.Column(db.Integer, default=10)
max_conversations = db.Column(db.Integer, default=10)
max_storage = db.Column(db.BigInteger, default=10737418240) # 10GB in bytes
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@classmethod
def get_settings(cls):
try:
settings = cls.query.first()
if not settings:
settings = cls(
max_rooms=10,
max_conversations=10,
max_storage=10737418240 # 10GB in bytes
)
db.session.add(settings)
db.session.commit()
return settings
except Exception as e:
# If there's an error (like integer overflow), rollback and return None
db.session.rollback()
return None
@classmethod
def get_usage_stats(cls):
settings = cls.get_settings()
if not settings:
# Return default values if settings can't be retrieved
return {
'max_rooms': 10,
'max_conversations': 10,
'max_storage': 10737418240,
'current_rooms': 0,
'current_conversations': 0,
'current_storage': 0,
'rooms_percentage': 0,
'conversations_percentage': 0,
'storage_percentage': 0
}
total_rooms = Room.query.count()
total_conversations = Conversation.query.count()
total_storage = db.session.query(db.func.sum(RoomFile.size)).filter(RoomFile.deleted == False).scalar() or 0
return {
'max_rooms': settings.max_rooms,
'max_conversations': settings.max_conversations,
'max_storage': settings.max_storage,
'current_rooms': total_rooms,
'current_conversations': total_conversations,
'current_storage': total_storage,
'rooms_percentage': (total_rooms / settings.max_rooms) * 100 if settings.max_rooms > 0 else 0,
'conversations_percentage': (total_conversations / settings.max_conversations) * 100 if settings.max_conversations > 0 else 0,
'storage_percentage': (total_storage / settings.max_storage) * 100 if settings.max_storage > 0 else 0
}
class KeyValueSettings(db.Model): class KeyValueSettings(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(100), unique=True, nullable=False) key = db.Column(db.String(100), unique=True, nullable=False)
@@ -261,10 +197,10 @@ class Conversation(db.Model):
name = db.Column(db.String(100), nullable=False) name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships # Relationships
creator = db.relationship('User', backref=db.backref('created_conversations', cascade='all, delete-orphan'), foreign_keys=[created_by]) creator = db.relationship('User', backref='created_conversations', foreign_keys=[created_by])
members = db.relationship('User', secondary=conversation_members, backref=db.backref('conversations', lazy='dynamic')) members = db.relationship('User', secondary=conversation_members, backref=db.backref('conversations', lazy='dynamic'))
messages = db.relationship('Message', back_populates='conversation', cascade='all, delete-orphan') messages = db.relationship('Message', back_populates='conversation', cascade='all, delete-orphan')
@@ -276,11 +212,11 @@ class Message(db.Model):
content = db.Column(db.Text, nullable=False) content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id'), nullable=False) conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships # Relationships
conversation = db.relationship('Conversation', back_populates='messages') conversation = db.relationship('Conversation', back_populates='messages')
user = db.relationship('User', backref=db.backref('messages', cascade='all, delete-orphan')) user = db.relationship('User', backref='messages')
attachments = db.relationship('MessageAttachment', back_populates='message', cascade='all, delete-orphan') attachments = db.relationship('MessageAttachment', back_populates='message', cascade='all, delete-orphan')
def __repr__(self): def __repr__(self):
@@ -348,14 +284,14 @@ class Event(db.Model):
__tablename__ = 'events' __tablename__ = 'events'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
event_type = db.Column(db.String(50), nullable=False) event_type = db.Column(db.String(50), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
details = db.Column(db.JSON) # Store additional event-specific data details = db.Column(db.JSON) # Store additional event-specific data
ip_address = db.Column(db.String(45)) # IPv6 addresses can be up to 45 chars ip_address = db.Column(db.String(45)) # IPv6 addresses can be up to 45 chars
user_agent = db.Column(db.String(255)) user_agent = db.Column(db.String(255))
# Relationships # Relationships
user = db.relationship('User', backref=db.backref('events', cascade='all, delete-orphan')) user = db.relationship('User', backref='events')
def __repr__(self): def __repr__(self):
return f'<Event {self.event_type} by User {self.user_id} at {self.timestamp}>' return f'<Event {self.event_type} by User {self.user_id} at {self.timestamp}>'
@@ -380,14 +316,14 @@ class Notif(db.Model):
__tablename__ = 'notifs' __tablename__ = 'notifs'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
notif_type = db.Column(db.String(50), nullable=False) notif_type = db.Column(db.String(50), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
sender_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=True) sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
read = db.Column(db.Boolean, default=False, nullable=False) read = db.Column(db.Boolean, default=False, nullable=False)
details = db.Column(db.JSON) # Store additional notification-specific data details = db.Column(db.JSON) # Store additional notification-specific data
# Relationships # Relationships
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('notifications', cascade='all, delete-orphan')) user = db.relationship('User', foreign_keys=[user_id], backref='notifications')
sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_notifications') sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_notifications')
def __repr__(self): def __repr__(self):
@@ -401,11 +337,11 @@ class EmailTemplate(db.Model):
body = db.Column(db.Text, nullable=False) body = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=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) created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
is_active = db.Column(db.Boolean, default=True) is_active = db.Column(db.Boolean, default=True)
# Relationships # Relationships
creator = db.relationship('User', backref=db.backref('created_email_templates', cascade='all, delete-orphan'), foreign_keys=[created_by]) creator = db.relationship('User', backref='created_email_templates', foreign_keys=[created_by])
def __repr__(self): def __repr__(self):
return f'<EmailTemplate {self.name}>' return f'<EmailTemplate {self.name}>'
@@ -428,253 +364,3 @@ class Mail(db.Model):
def __repr__(self): def __repr__(self):
return f'<Mail to {self.recipient} status={self.status}>' return f'<Mail to {self.recipient} status={self.status}>'
class PasswordSetupToken(db.Model):
__tablename__ = 'password_setup_tokens'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
token = db.Column(db.String(100), unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expires_at = db.Column(db.DateTime, nullable=False)
used = db.Column(db.Boolean, default=False)
# Relationships
user = db.relationship('User', backref=db.backref('password_setup_tokens', cascade='all, delete-orphan'))
def is_valid(self):
return not self.used and datetime.utcnow() < self.expires_at
def __repr__(self):
return f'<PasswordSetupToken {self.token}>'
class PasswordResetToken(db.Model):
__tablename__ = 'password_reset_tokens'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
token = db.Column(db.String(100), unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expires_at = db.Column(db.DateTime, nullable=False)
used = db.Column(db.Boolean, default=False)
ip_address = db.Column(db.String(45)) # Store IP address for security
# Relationships
user = db.relationship('User', backref=db.backref('password_reset_tokens', cascade='all, delete-orphan'))
def is_valid(self):
return not self.used and datetime.utcnow() < self.expires_at
def __repr__(self):
return f'<PasswordResetToken {self.token}>'
def user_has_permission(room, perm_name):
"""
Check if the current user has a specific permission in a room.
Args:
room: Room object
perm_name: Name of the permission to check (e.g., 'can_view', 'can_upload')
Returns:
bool: True if user has permission, False otherwise
"""
# Admin and manager users have all permissions
if current_user.is_admin or current_user.is_manager:
return True
# Check if user is a member of the room
if current_user not in room.members:
return False
# Get user's permissions for this room
permission = RoomMemberPermission.query.filter_by(
room_id=room.id,
user_id=current_user.id
).first()
# If no specific permissions are set, user only has view access
if not permission:
return perm_name == 'can_view'
# Check the specific permission
return getattr(permission, perm_name, False)
class ManagementAPIKey(db.Model):
__tablename__ = 'management_api_keys'
id = db.Column(db.Integer, primary_key=True)
api_key = db.Column(db.String(100), unique=True, nullable=False)
name = db.Column(db.String(100), nullable=False) # Name/description of the management tool
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_used_at = db.Column(db.DateTime)
is_active = db.Column(db.Boolean, default=True)
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='SET NULL'))
# Relationships
creator = db.relationship('User', backref=db.backref('created_api_keys', cascade='all, delete-orphan'))
def __repr__(self):
return f'<ManagementAPIKey {self.name}>'
class Instance(db.Model):
__tablename__ = 'instances'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
company = db.Column(db.String(100), nullable=False)
rooms_count = db.Column(db.Integer, nullable=False, default=0)
conversations_count = db.Column(db.Integer, nullable=False, default=0)
data_size = db.Column(db.Float, nullable=False, default=0.0)
payment_plan = db.Column(db.String(20), nullable=False, default='Basic')
main_url = db.Column(db.String(255), unique=True, nullable=False)
status = db.Column(db.String(20), nullable=False, default='inactive')
status_details = db.Column(db.Text, nullable=True)
connection_token = db.Column(db.String(64), unique=True, nullable=True)
# Portainer integration fields
portainer_stack_id = db.Column(db.String(100), nullable=True) # Portainer stack ID
portainer_stack_name = db.Column(db.String(100), nullable=True) # Portainer stack name
# Version tracking fields
deployed_version = db.Column(db.String(50), nullable=True) # Current deployed version (commit hash or tag)
deployed_branch = db.Column(db.String(100), nullable=True) # Branch that was deployed
latest_version = db.Column(db.String(50), nullable=True) # Latest version available in Git
version_checked_at = db.Column(db.DateTime, nullable=True) # When version was last checked
created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP'))
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP'), onupdate=db.text('CURRENT_TIMESTAMP'))
def __repr__(self):
return f'<Instance {self.name}>'
class HelpArticle(db.Model):
__tablename__ = 'help_articles'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
category = db.Column(db.String(50), nullable=False) # getting-started, user-management, file-management, communication, security, administration
body = db.Column(db.Text, nullable=False) # Rich text content
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)
is_published = db.Column(db.Boolean, default=True)
order_index = db.Column(db.Integer, default=0) # For ordering articles within categories
# Relationships
creator = db.relationship('User', backref=db.backref('created_help_articles', cascade='all, delete-orphan'), foreign_keys=[created_by])
def __repr__(self):
return f'<HelpArticle {self.title} ({self.category})>'
@classmethod
def get_categories(cls):
"""Get all available categories with their display names"""
return {
'getting-started': 'Getting Started',
'user-management': 'User Management',
'file-management': 'File Management',
'communication': 'Communication',
'security': 'Security & Privacy',
'administration': 'Administration'
}
@classmethod
def get_articles_by_category(cls, category, published_only=True):
"""Get articles for a specific category"""
query = cls.query.filter_by(category=category)
if published_only:
query = query.filter_by(is_published=True)
return query.order_by(cls.order_index.asc(), cls.created_at.desc()).all()
@classmethod
def get_all_published(cls):
"""Get all published articles grouped by category"""
articles = cls.query.filter_by(is_published=True).order_by(cls.order_index.asc(), cls.created_at.desc()).all()
grouped = {}
for article in articles:
if article.category not in grouped:
grouped[article.category] = []
grouped[article.category].append(article)
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'<PricingPlan {self.name}>'
@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

View File

@@ -1,16 +1,17 @@
Flask>=2.0.0 Flask>=2.0.0
Flask-SQLAlchemy>=3.0.0 Flask-SQLAlchemy>=3.0.0
Flask-Login>=0.6.0 Flask-Login>=0.6.0
Flask-Mail==0.9.1
Flask-Migrate>=4.0.0
Flask-WTF>=1.0.0 Flask-WTF>=1.0.0
email-validator==2.1.0.post1 Flask-Migrate>=4.0.0
python-dotenv>=0.19.0
Werkzeug>=2.0.0
SQLAlchemy>=1.4.0 SQLAlchemy>=1.4.0
alembic>=1.7.0 Werkzeug>=2.0.0
WTForms==3.1.1
python-dotenv>=0.19.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
requests>=2.31.0
gunicorn==21.2.0 gunicorn==21.2.0
email_validator==2.1.0.post1
celery>=5.3.0
redis>=4.5.0
alembic>=1.7.0
flower>=2.0.0
prometheus-client>=0.16.0 prometheus-client>=0.16.0
PyJWT>=2.8.0

View File

@@ -1,14 +1,12 @@
from flask import Blueprint, Flask, render_template from flask import Blueprint, Flask, render_template
from flask_login import login_required from flask_login import login_required
from models import SiteSettings from models import SiteSettings
import os
def init_app(app: Flask): def init_app(app: Flask):
# Create blueprints # Create blueprints
main_bp = Blueprint('main', __name__) main_bp = Blueprint('main', __name__)
auth_bp = Blueprint('auth', __name__, url_prefix='/auth') auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
rooms_bp = Blueprint('rooms', __name__) rooms_bp = Blueprint('rooms', __name__)
public_bp = Blueprint('public', __name__)
# Import and initialize routes # Import and initialize routes
from .main import init_routes as init_main_routes from .main import init_routes as init_main_routes
@@ -19,12 +17,10 @@ def init_app(app: Flask):
from .admin import admin as admin_routes from .admin import admin as admin_routes
from .email_templates import email_templates as email_templates_routes from .email_templates import email_templates as email_templates_routes
from .user import user_bp as user_routes from .user import user_bp as user_routes
from .public import init_public_routes
# Initialize routes # Initialize routes
init_main_routes(main_bp) init_main_routes(main_bp)
init_auth_routes(auth_bp) init_auth_routes(auth_bp)
init_public_routes(public_bp)
# Add site_settings context processor to all blueprints # Add site_settings context processor to all blueprints
@app.context_processor @app.context_processor
@@ -32,15 +28,9 @@ def init_app(app: Flask):
site_settings = SiteSettings.query.first() site_settings = SiteSettings.query.first()
return dict(site_settings=site_settings) return dict(site_settings=site_settings)
# Add MASTER environment variable to all templates
@app.context_processor
def inject_master():
return dict(is_master=os.environ.get('MASTER', 'false').lower() == 'true')
# Register blueprints # Register blueprints
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(public_bp)
app.register_blueprint(rooms_routes) app.register_blueprint(rooms_routes)
app.register_blueprint(contacts_routes) app.register_blueprint(contacts_routes)
app.register_blueprint(conversations_routes) app.register_blueprint(conversations_routes)

View File

@@ -1,9 +1,8 @@
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle from models import db, Room, RoomFile, User
import os import os
from datetime import datetime from datetime import datetime
import json
admin = Blueprint('admin', __name__) admin = Blueprint('admin', __name__)
@@ -14,7 +13,7 @@ def sync_files():
return jsonify({'error': 'Unauthorized'}), 403 return jsonify({'error': 'Unauthorized'}), 403
try: try:
DATA_ROOT = '/app/uploads/rooms' DATA_ROOT = '/data/rooms'
admin_user = User.query.filter_by(is_admin=True).first() admin_user = User.query.filter_by(is_admin=True).first()
if not admin_user: if not admin_user:
return jsonify({'error': 'No admin user found'}), 500 return jsonify({'error': 'No admin user found'}), 500
@@ -74,7 +73,7 @@ def verify_db_state():
return jsonify({'error': 'Unauthorized'}), 403 return jsonify({'error': 'Unauthorized'}), 403
try: try:
DATA_ROOT = '/app/uploads/rooms' DATA_ROOT = '/data/rooms'
verification_results = { verification_results = {
'rooms_checked': 0, 'rooms_checked': 0,
'files_in_db_not_fs': [], 'files_in_db_not_fs': [],
@@ -209,7 +208,7 @@ def cleanup_orphaned_records():
return jsonify({'error': 'Unauthorized'}), 403 return jsonify({'error': 'Unauthorized'}), 403
try: try:
DATA_ROOT = '/app/uploads/rooms' DATA_ROOT = '/data/rooms'
rooms = Room.query.all() rooms = Room.query.all()
cleaned_records = [] cleaned_records = []
@@ -243,486 +242,3 @@ def cleanup_orphaned_records():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/usage-stats', methods=['GET'])
@login_required
def get_usage_stats():
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
stats = DocuPulseSettings.get_usage_stats()
return jsonify(stats)
except Exception as e:
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['PUT'])
@login_required
def update_help_article(article_id):
"""Update a help article"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
article = HelpArticle.query.get_or_404(article_id)
title = request.form.get('title')
category = request.form.get('category')
body = request.form.get('body')
order_index = int(request.form.get('order_index', 0))
is_published = request.form.get('is_published') == 'true'
if not title or not category or not body:
return jsonify({'error': 'Title, category, and body are required'}), 400
# Validate category
valid_categories = HelpArticle.get_categories().keys()
if category not in valid_categories:
return jsonify({'error': 'Invalid category'}), 400
article.title = title
article.category = category
article.body = body
article.order_index = order_index
article.is_published = is_published
article.updated_at = datetime.utcnow()
db.session.commit()
# Log the event
log_event(
event_type='help_article_update',
details={
'article_id': article.id,
'title': article.title,
'category': article.category,
'updated_by': f"{current_user.username} {current_user.last_name}"
},
user_id=current_user.id
)
db.session.commit()
return jsonify({'success': True, 'message': 'Article updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['DELETE'])
@login_required
def delete_help_article(article_id):
"""Delete a help article"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
article = HelpArticle.query.get_or_404(article_id)
# Log the event before deletion
log_event(
event_type='help_article_delete',
details={
'article_id': article.id,
'title': article.title,
'category': article.category,
'deleted_by': f"{current_user.username} {current_user.last_name}"
},
user_id=current_user.id
)
db.session.delete(article)
db.session.commit()
return jsonify({'success': True, 'message': 'Article deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
# Help Articles API endpoints
@admin.route('/api/admin/help-articles', methods=['GET'])
@login_required
def get_help_articles():
"""Get all help articles"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
articles = HelpArticle.query.order_by(HelpArticle.category.asc(), HelpArticle.order_index.asc(), HelpArticle.created_at.desc()).all()
articles_data = []
for article in articles:
articles_data.append({
'id': article.id,
'title': article.title,
'category': article.category,
'body': article.body,
'created_at': article.created_at.isoformat() if article.created_at else None,
'updated_at': article.updated_at.isoformat() if article.updated_at else None,
'created_by': article.created_by,
'is_published': article.is_published,
'order_index': article.order_index
})
return jsonify({'articles': articles_data})
@admin.route('/api/admin/help-articles', methods=['POST'])
@login_required
def create_help_article():
"""Create a new help article"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
title = request.form.get('title')
category = request.form.get('category')
body = request.form.get('body')
order_index = int(request.form.get('order_index', 0))
is_published = request.form.get('is_published') == 'true'
if not title or not category or not body:
return jsonify({'error': 'Title, category, and body are required'}), 400
# Validate category
valid_categories = HelpArticle.get_categories().keys()
if category not in valid_categories:
return jsonify({'error': 'Invalid category'}), 400
article = HelpArticle(
title=title,
category=category,
body=body,
order_index=order_index,
is_published=is_published,
created_by=current_user.id
)
db.session.add(article)
db.session.commit()
# Log the event
log_event(
event_type='help_article_create',
details={
'article_id': article.id,
'title': article.title,
'category': article.category,
'created_by': f"{current_user.username} {current_user.last_name}"
},
user_id=current_user.id
)
db.session.commit()
return jsonify({'success': True, 'message': 'Article created successfully', 'article_id': article.id})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['GET'])
@login_required
def get_help_article(article_id):
"""Get a specific help article"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
article = HelpArticle.query.get_or_404(article_id)
article_data = {
'id': article.id,
'title': article.title,
'category': article.category,
'body': article.body,
'created_at': article.created_at.isoformat() if article.created_at else None,
'updated_at': article.updated_at.isoformat() if article.updated_at else None,
'created_by': article.created_by,
'is_published': article.is_published,
'order_index': article.order_index
}
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/<int:plan_id>', 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/<int:plan_id>', 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/<int:plan_id>', 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/<int:plan_id>/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
@admin.route('/api/admin/pricing-plans', methods=['GET'])
@login_required
def get_pricing_plans():
"""Get all active pricing plans for instance launch"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
from models import PricingPlan
# Get all active pricing plans ordered by order_index
plans = PricingPlan.query.filter_by(is_active=True).order_by(PricingPlan.order_index).all()
plans_data = []
for plan in plans:
plans_data.append({
'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,
'format_quota_display': {
'room_quota': plan.format_quota_display('room_quota'),
'conversation_quota': plan.format_quota_display('conversation_quota'),
'storage_quota_gb': plan.format_quota_display('storage_quota_gb'),
'manager_quota': plan.format_quota_display('manager_quota'),
'admin_quota': plan.format_quota_display('admin_quota')
}
})
return jsonify({
'success': True,
'plans': plans_data
})
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -1,598 +0,0 @@
from flask import Blueprint, jsonify, request, current_app, make_response, flash, redirect, url_for
from functools import wraps
from models import (
KeyValueSettings, User, Room, Conversation, RoomFile,
SiteSettings, DocuPulseSettings, Event, Mail, ManagementAPIKey, PasswordSetupToken, PasswordResetToken,
HelpArticle
)
from extensions import db, csrf
from utils import log_event
from datetime import datetime, timedelta
import os
import jwt
from werkzeug.security import generate_password_hash
import secrets
from flask_login import login_user
admin_api = Blueprint('admin_api', __name__)
def add_cors_headers(response):
"""Add CORS headers to the response"""
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key, X-CSRF-Token'
return response
@admin_api.before_request
def handle_preflight():
"""Handle preflight requests"""
if request.method == 'OPTIONS':
response = make_response()
return add_cors_headers(response)
@admin_api.after_request
def after_request(response):
"""Add CORS headers to all responses"""
return add_cors_headers(response)
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
token = None
if 'Authorization' in request.headers:
token = request.headers['Authorization'].split(" ")[1]
if not token:
return jsonify({'message': 'Token is missing!'}), 401
try:
data = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=["HS256"])
# Check if it's a management tool token
if data.get('is_management'):
return f(None, *args, **kwargs) # Pass None as current_user for management tool
current_user = User.query.get(data['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'message': 'Invalid token or insufficient permissions!'}), 401
except:
return jsonify({'message': 'Invalid token!'}), 401
return f(current_user, *args, **kwargs)
return decorated
def generate_management_api_key():
"""Generate a secure API key for the management tool"""
return secrets.token_urlsafe(32)
def validate_management_api_key(api_key):
"""Validate if the provided API key is valid"""
key = ManagementAPIKey.query.filter_by(api_key=api_key, is_active=True).first()
if key:
key.last_used_at = datetime.utcnow()
db.session.commit()
return True
return False
@admin_api.route('/login', methods=['POST'])
@csrf.exempt
def admin_login():
try:
# Check if this is an API request
is_api_request = request.headers.get('Accept') == 'application/json' or \
request.headers.get('Content-Type') == 'application/json'
if is_api_request:
data = request.get_json()
else:
data = request.form
if not data or 'email' not in data or 'password' not in data:
if is_api_request:
return jsonify({
'message': 'Email and password are required',
'status': 'error'
}), 400
flash('Email and password are required', 'error')
return redirect(url_for('auth.login'))
user = User.query.filter_by(email=data['email']).first()
if not user or not user.is_admin or not user.check_password(data['password']):
if is_api_request:
return jsonify({
'message': 'Invalid credentials or not an admin',
'status': 'error'
}), 401
flash('Invalid credentials or not an admin', 'error')
return redirect(url_for('auth.login'))
# For API requests, return JWT token
if is_api_request:
token = jwt.encode({
'user_id': user.id,
'is_admin': True,
'exp': datetime.utcnow() + timedelta(days=1) # Token expires in 1 day
}, current_app.config['SECRET_KEY'], algorithm="HS256")
return jsonify({
'token': token,
'status': 'success'
}), 200
# For web requests, use session-based auth
login_user(user)
return redirect(url_for('main.dashboard'))
except Exception as e:
current_app.logger.error(f"Login error: {str(e)}")
if is_api_request:
return jsonify({
'message': 'An error occurred during login',
'status': 'error'
}), 500
flash('An error occurred during login', 'error')
return redirect(url_for('auth.login'))
@admin_api.route('/management-token', methods=['POST'])
@csrf.exempt
def get_management_token():
"""Generate a JWT token for the management tool using API key authentication"""
api_key = request.headers.get('X-API-Key')
if not api_key or not validate_management_api_key(api_key):
return jsonify({'message': 'Invalid API key'}), 401
# Create a token without expiration
token = jwt.encode({
'user_id': 0, # Special user ID for management tool
'is_management': True
}, current_app.config['SECRET_KEY'], algorithm="HS256")
return jsonify({
'token': token
})
@admin_api.route('/management-api-key', methods=['POST'])
@csrf.exempt
@token_required
def create_management_api_key(current_user):
"""Create a new API key for the management tool (only accessible by admin users)"""
if not current_user.is_admin:
return jsonify({'message': 'Insufficient permissions'}), 403
data = request.get_json()
if not data or 'name' not in data:
return jsonify({'message': 'Name is required'}), 400
api_key = generate_management_api_key()
key = ManagementAPIKey(
api_key=api_key,
name=data['name'],
created_by=current_user.id
)
db.session.add(key)
db.session.commit()
return jsonify({
'api_key': api_key,
'name': key.name,
'created_at': key.created_at.isoformat(),
'message': 'API key generated successfully. Store this key securely as it will not be shown again.'
}), 201
@admin_api.route('/management-api-keys', methods=['GET'])
@csrf.exempt
@token_required
def list_management_api_keys(current_user):
"""List all management API keys (only accessible by admin users)"""
if not current_user.is_admin:
return jsonify({'message': 'Insufficient permissions'}), 403
keys = ManagementAPIKey.query.all()
return jsonify([{
'id': key.id,
'name': key.name,
'created_at': key.created_at.isoformat(),
'last_used_at': key.last_used_at.isoformat() if key.last_used_at else None,
'is_active': key.is_active,
'created_by': key.created_by
} for key in keys])
@admin_api.route('/management-api-key/<int:key_id>', methods=['DELETE'])
@csrf.exempt
@token_required
def revoke_management_api_key(current_user, key_id):
"""Revoke a management API key (only accessible by admin users)"""
if not current_user.is_admin:
return jsonify({'message': 'Insufficient permissions'}), 403
key = ManagementAPIKey.query.get(key_id)
if not key:
return jsonify({'message': 'API key not found'}), 404
key.is_active = False
db.session.commit()
return jsonify({'message': 'API key revoked successfully'})
# Key-Value Settings CRUD
@admin_api.route('/key-value', methods=['GET'])
@csrf.exempt
@token_required
def get_key_values(current_user):
settings = KeyValueSettings.query.all()
return jsonify([{'key': s.key, 'value': s.value} for s in settings])
@admin_api.route('/key-value/<key>', methods=['GET'])
@token_required
def get_key_value(current_user, key):
setting = KeyValueSettings.query.filter_by(key=key).first()
if not setting:
return jsonify({'message': 'Key not found'}), 404
return jsonify({'key': setting.key, 'value': setting.value})
@admin_api.route('/key-value', methods=['POST'])
@csrf.exempt
@token_required
def create_key_value(current_user):
data = request.get_json()
if not data or 'key' not in data or 'value' not in data:
return jsonify({'message': 'Missing key or value'}), 400
setting = KeyValueSettings(key=data['key'], value=data['value'])
db.session.add(setting)
db.session.commit()
return jsonify({'message': 'Key-value pair created successfully'}), 201
@admin_api.route('/key-value/<key>', methods=['PUT'])
@csrf.exempt
@token_required
def update_key_value(current_user, key):
setting = KeyValueSettings.query.filter_by(key=key).first()
if not setting:
return jsonify({'message': 'Key not found'}), 404
data = request.get_json()
if not data or 'value' not in data:
return jsonify({'message': 'Missing value'}), 400
setting.value = data['value']
db.session.commit()
return jsonify({'message': 'Key-value pair updated successfully'})
@admin_api.route('/key-value/<key>', methods=['DELETE'])
@csrf.exempt
@token_required
def delete_key_value(current_user, key):
setting = KeyValueSettings.query.filter_by(key=key).first()
if not setting:
return jsonify({'message': 'Key not found'}), 404
db.session.delete(setting)
db.session.commit()
return jsonify({'message': 'Key-value pair deleted successfully'})
# Contacts (Users) CRUD
@admin_api.route('/contacts', methods=['GET'])
@csrf.exempt
@token_required
def get_contacts(current_user):
users = User.query.all()
return jsonify([{
'id': user.id,
'username': user.username,
'email': user.email,
'last_name': user.last_name,
'phone': user.phone,
'company': user.company,
'position': user.position,
'is_active': user.is_active,
'is_admin': user.is_admin,
'is_manager': user.is_manager,
'role': 'admin' if user.is_admin else 'manager' if user.is_manager else 'user',
'created_at': user.created_at.isoformat()
} for user in users])
@admin_api.route('/contacts/<int:user_id>', methods=['GET'])
@csrf.exempt
@token_required
def get_contact(current_user, user_id):
user = User.query.get(user_id)
if not user:
return jsonify({'message': 'User not found'}), 404
return jsonify({
'id': user.id,
'username': user.username,
'email': user.email,
'last_name': user.last_name,
'phone': user.phone,
'company': user.company,
'position': user.position,
'is_active': user.is_active,
'is_admin': user.is_admin,
'is_manager': user.is_manager,
'role': 'admin' if user.is_admin else 'manager' if user.is_manager else 'user',
'created_at': user.created_at.isoformat()
})
@admin_api.route('/contacts', methods=['POST'])
@csrf.exempt
@token_required
def create_contact(current_user):
data = request.get_json()
required_fields = ['username', 'email', 'last_name', 'role']
if not all(field in data for field in required_fields):
return jsonify({'message': 'Missing required fields'}), 400
if User.query.filter_by(email=data['email']).first():
return jsonify({'message': 'Email already exists'}), 400
# Validate role
if data['role'] not in ['admin', 'manager', 'user']:
return jsonify({'message': 'Invalid role'}), 400
user = User(
username=data['username'],
email=data['email'],
last_name=data['last_name'],
phone=data.get('phone'),
company=data.get('company'),
position=data.get('position'),
is_admin=data['role'] == 'admin',
is_manager=data['role'] == 'manager'
)
user.set_password(data.get('password', 'changeme'))
db.session.add(user)
db.session.commit()
return jsonify({'message': 'Contact created successfully', 'id': user.id}), 201
@admin_api.route('/contacts/<int:user_id>', methods=['PUT'])
@csrf.exempt
@token_required
def update_contact(current_user, user_id):
user = User.query.get(user_id)
if not user:
return jsonify({'message': 'User not found'}), 404
data = request.get_json()
if 'email' in data and data['email'] != user.email:
if User.query.filter_by(email=data['email']).first():
return jsonify({'message': 'Email already exists'}), 400
user.email = data['email']
# Update role if provided
if 'role' in data:
if data['role'] not in ['admin', 'manager', 'user']:
return jsonify({'message': 'Invalid role'}), 400
user.is_admin = data['role'] == 'admin'
user.is_manager = data['role'] == 'manager'
user.username = data.get('username', user.username)
user.last_name = data.get('last_name', user.last_name)
user.phone = data.get('phone', user.phone)
user.company = data.get('company', user.company)
user.position = data.get('position', user.position)
user.is_active = data.get('is_active', user.is_active)
if 'password' in data:
user.set_password(data['password'])
db.session.commit()
return jsonify({'message': 'Contact updated successfully'})
@admin_api.route('/contacts/<int:user_id>', methods=['DELETE'])
@csrf.exempt
@token_required
def delete_contact(current_user, user_id):
user = User.query.get(user_id)
if not user:
return jsonify({'message': 'User not found'}), 404
db.session.delete(user)
db.session.commit()
return jsonify({'message': 'Contact deleted successfully'})
# Statistics
@admin_api.route('/statistics', methods=['GET'])
@csrf.exempt
@token_required
def get_statistics(current_user):
room_count = Room.query.count()
conversation_count = Conversation.query.count()
# Calculate total storage
total_storage = 0
for file in RoomFile.query.all():
if file.size:
total_storage += file.size
return jsonify({
'rooms': room_count,
'conversations': conversation_count,
'total_storage': total_storage
})
# Website Settings CRUD
@admin_api.route('/settings', methods=['GET'])
@csrf.exempt
@token_required
def get_settings(current_user):
settings = SiteSettings.get_settings()
return jsonify({
'primary_color': settings.primary_color,
'secondary_color': settings.secondary_color,
'company_name': settings.company_name,
'company_logo': settings.company_logo,
'company_website': settings.company_website,
'company_email': settings.company_email,
'company_phone': settings.company_phone,
'company_address': settings.company_address,
'company_city': settings.company_city,
'company_state': settings.company_state,
'company_zip': settings.company_zip,
'company_country': settings.company_country,
'company_description': settings.company_description,
'company_industry': settings.company_industry
})
@admin_api.route('/settings', methods=['PUT'])
@csrf.exempt
@token_required
def update_settings(current_user):
settings = SiteSettings.get_settings()
data = request.get_json()
for key, value in data.items():
if hasattr(settings, key):
setattr(settings, key, value)
db.session.commit()
return jsonify({'message': 'Settings updated successfully'})
# Website Logs
@admin_api.route('/logs', methods=['GET'])
@csrf.exempt
@token_required
def get_logs(current_user):
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
events = Event.query.order_by(Event.timestamp.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'events': [{
'id': event.id,
'event_type': event.event_type,
'user_id': event.user_id,
'timestamp': event.timestamp.isoformat(),
'details': event.details,
'ip_address': event.ip_address,
'user_agent': event.user_agent
} for event in events.items],
'total': events.total,
'pages': events.pages,
'current_page': events.page
})
# Mail Logs
@admin_api.route('/mail-logs', methods=['GET'])
@csrf.exempt
@token_required
def get_mail_logs(current_user):
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
mails = Mail.query.order_by(Mail.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'mails': [{
'id': mail.id,
'recipient': mail.recipient,
'subject': mail.subject,
'status': mail.status,
'created_at': mail.created_at.isoformat(),
'sent_at': mail.sent_at.isoformat() if mail.sent_at else None,
'template_id': mail.template_id
} for mail in mails.items],
'total': mails.total,
'pages': mails.pages,
'current_page': mails.page
})
# Resend Setup Mail
@admin_api.route('/resend-setup-mail/<int:user_id>', methods=['POST'])
@csrf.exempt
@token_required
def resend_setup_mail(current_user, user_id):
user = User.query.get(user_id)
if not user:
return jsonify({'message': 'User not found'}), 404
# Generate a new password setup token
token = PasswordSetupToken(
user_id=user.id,
token=generate_password_hash(str(user.id) + str(datetime.utcnow())),
expires_at=datetime.utcnow() + timedelta(days=7)
)
db.session.add(token)
# Create mail record
mail = Mail(
recipient=user.email,
subject='DocuPulse Account Setup',
body=f'Please click the following link to set up your account: {request.host_url}setup-password/{token.token}',
status='pending'
)
db.session.add(mail)
db.session.commit()
return jsonify({'message': 'Setup mail queued for resending'})
# Generate Password Reset Token
@admin_api.route('/generate-password-reset/<int:user_id>', methods=['POST'])
@csrf.exempt
@token_required
def generate_password_reset_token(current_user, user_id):
user = User.query.get(user_id)
if not user:
return jsonify({'message': 'User not found'}), 404
# Generate a secure token for password reset
token = secrets.token_urlsafe(32)
# Create password reset token
reset_token = PasswordResetToken(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=24), # 24 hour expiration
ip_address=request.remote_addr
)
db.session.add(reset_token)
db.session.commit()
# Get the instance URL from the request data or use the current host
data = request.get_json() or {}
instance_url = data.get('instance_url', request.host_url.rstrip('/'))
# Return the token and reset URL - FIX: Include /auth prefix
reset_url = f"{instance_url}/auth/reset-password/{token}"
return jsonify({
'message': 'Password reset token generated successfully',
'token': token,
'reset_url': reset_url,
'expires_at': reset_token.expires_at.isoformat(),
'user_email': user.email
})
# Version Information
@admin_api.route('/version-info', methods=['GET'])
@csrf.exempt
@token_required
def get_version_info(current_user):
"""Get version information from environment variables"""
try:
version_info = {
'app_version': os.environ.get('APP_VERSION', 'unknown'),
'git_commit': os.environ.get('GIT_COMMIT', 'unknown'),
'git_branch': os.environ.get('GIT_BRANCH', 'unknown'),
'deployed_at': os.environ.get('DEPLOYED_AT', 'unknown'),
'ismaster': os.environ.get('ISMASTER', 'false'),
'port': os.environ.get('PORT', 'unknown'),
'pricing_tier_name': os.environ.get('PRICING_TIER_NAME', 'unknown')
}
return jsonify(version_info)
except Exception as e:
current_app.logger.error(f"Error getting version info: {str(e)}")
return jsonify({
'error': str(e),
'app_version': 'unknown',
'git_commit': 'unknown',
'git_branch': 'unknown',
'deployed_at': 'unknown',
'pricing_tier_name': 'unknown'
}), 500

View File

@@ -1,29 +1,16 @@
from flask import render_template, request, flash, redirect, url_for, Blueprint, jsonify from flask import render_template, request, flash, redirect, url_for, Blueprint, jsonify
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from models import db, User, Notif, PasswordSetupToken, PasswordResetToken from models import db, User, Notif
from functools import wraps from functools import wraps
from datetime import datetime, timedelta from datetime import datetime
from utils import log_event, create_notification, get_unread_count from utils import log_event, create_notification, get_unread_count
from utils.notification import generate_mail_from_notification
import string
import secrets
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
def require_password_change(f): def require_password_change(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if current_user.is_authenticated: if current_user.is_authenticated and current_user.check_password('changeme'):
# Check if user has any valid password setup tokens
has_valid_token = PasswordSetupToken.query.filter_by(
user_id=current_user.id,
used=False
).filter(PasswordSetupToken.expires_at > datetime.utcnow()).first() is not None
if has_valid_token:
flash('Please set up your password before continuing.', 'warning')
return redirect(url_for('auth.setup_password', token=current_user.password_setup_tokens[0].token))
elif current_user.check_password('changeme'):
flash('Please change your password before continuing.', 'warning') flash('Please change your password before continuing.', 'warning')
return redirect(url_for('auth.change_password')) return redirect(url_for('auth.change_password'))
return f(*args, **kwargs) return f(*args, **kwargs)
@@ -229,234 +216,3 @@ def init_routes(auth_bp):
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
return render_template('auth/change_password.html') return render_template('auth/change_password.html')
@auth_bp.route('/setup-password/<token>', methods=['GET', 'POST'])
def setup_password(token):
# Find the token
setup_token = PasswordSetupToken.query.filter_by(token=token).first()
if not setup_token or not setup_token.is_valid():
flash('Invalid or expired password setup link. Please contact your administrator for a new link.', 'error')
return redirect(url_for('auth.login'))
if request.method == 'POST':
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
if not password or not confirm_password:
flash('Please fill in all fields.', 'error')
return render_template('auth/setup_password.html')
if password != confirm_password:
flash('Passwords do not match.', 'error')
return render_template('auth/setup_password.html')
# Password requirements
if len(password) < 8:
flash('Password must be at least 8 characters long.', 'error')
return render_template('auth/setup_password.html')
if not any(c.isupper() for c in password):
flash('Password must contain at least one uppercase letter.', 'error')
return render_template('auth/setup_password.html')
if not any(c.islower() for c in password):
flash('Password must contain at least one lowercase letter.', 'error')
return render_template('auth/setup_password.html')
if not any(c.isdigit() for c in password):
flash('Password must contain at least one number.', 'error')
return render_template('auth/setup_password.html')
if not any(c in string.punctuation for c in password):
flash('Password must contain at least one special character.', 'error')
return render_template('auth/setup_password.html')
# Update user's password
user = setup_token.user
user.set_password(password)
# Mark token as used
setup_token.used = True
# Create password change notification
create_notification(
notif_type='password_changed',
user_id=user.id,
details={
'message': 'Your password has been set up successfully.',
'timestamp': datetime.utcnow().isoformat()
}
)
# Log password setup event
log_event(
event_type='user_update',
user_id=user.id,
details={
'user_id': user.id,
'user_name': f"{user.username} {user.last_name}",
'update_type': 'password_setup',
'success': True
}
)
db.session.commit()
# Log the user in and redirect to dashboard
login_user(user)
flash('Password set up successfully! Welcome to DocuPulse.', 'success')
return redirect(url_for('main.dashboard'))
return render_template('auth/setup_password.html')
@auth_bp.route('/forgot-password', methods=['GET', 'POST'])
def forgot_password():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
email = request.form.get('email')
if not email:
flash('Please enter your email address.', 'error')
return render_template('auth/forgot_password.html')
# Check if user exists
user = User.query.filter_by(email=email).first()
if user:
# Generate a secure token
token = secrets.token_urlsafe(32)
# Create password reset token
reset_token = PasswordResetToken(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=1), # 1 hour expiration
ip_address=request.remote_addr
)
db.session.add(reset_token)
# Create notification for password reset
notif = create_notification(
notif_type='password_reset',
user_id=user.id,
details={
'message': 'You requested a password reset. Click the link below to reset your password.',
'reset_link': url_for('auth.reset_password', token=token, _external=True),
'expiry_time': (datetime.utcnow() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S UTC'),
'ip_address': request.remote_addr,
'timestamp': datetime.utcnow().isoformat()
},
generate_mail=False # Don't auto-generate email, we'll do it manually
)
# Generate and send email manually
if notif:
generate_mail_from_notification(notif)
# Log the password reset request
log_event(
event_type='user_update',
details={
'user_id': user.id,
'user_name': f"{user.username} {user.last_name}",
'email': user.email,
'update_type': 'password_reset_request',
'ip_address': request.remote_addr,
'success': True
}
)
db.session.commit()
# Always show success message to prevent email enumeration
flash('If an account with that email exists, a password reset link has been sent to your email address.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/forgot_password.html')
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
# Find the token
reset_token = PasswordResetToken.query.filter_by(token=token).first()
if not reset_token or not reset_token.is_valid():
flash('Invalid or expired password reset link. Please request a new password reset.', 'error')
return redirect(url_for('auth.forgot_password'))
if request.method == 'POST':
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
if not password or not confirm_password:
flash('Please fill in all fields.', 'error')
return render_template('auth/reset_password.html', token=token)
if password != confirm_password:
flash('Passwords do not match.', 'error')
return render_template('auth/reset_password.html', token=token)
# Password requirements
if len(password) < 8:
flash('Password must be at least 8 characters long.', 'error')
return render_template('auth/reset_password.html', token=token)
if not any(c.isupper() for c in password):
flash('Password must contain at least one uppercase letter.', 'error')
return render_template('auth/reset_password.html', token=token)
if not any(c.islower() for c in password):
flash('Password must contain at least one lowercase letter.', 'error')
return render_template('auth/reset_password.html', token=token)
if not any(c.isdigit() for c in password):
flash('Password must contain at least one number.', 'error')
return render_template('auth/reset_password.html', token=token)
if not any(c in string.punctuation for c in password):
flash('Password must contain at least one special character.', 'error')
return render_template('auth/reset_password.html', token=token)
# Update user's password
user = reset_token.user
user.set_password(password)
# Mark token as used
reset_token.used = True
# Create password change notification
create_notification(
notif_type='password_changed',
user_id=user.id,
details={
'message': 'Your password has been reset successfully.',
'timestamp': datetime.utcnow().isoformat()
}
)
# Log password reset event
log_event(
event_type='user_update',
details={
'user_id': user.id,
'user_name': f"{user.username} {user.last_name}",
'email': user.email,
'update_type': 'password_reset',
'ip_address': request.remote_addr,
'success': True
}
)
db.session.commit()
# Log the user in and redirect to dashboard
login_user(user)
flash('Password reset successfully! Welcome back to DocuPulse.', 'success')
return redirect(url_for('main.dashboard'))
return render_template('auth/reset_password.html', token=token)

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from models import db, User, Notif, PasswordSetupToken from models import db, User, Notif
from forms import UserForm from forms import UserForm
from flask import abort from flask import abort
from sqlalchemy import or_ from sqlalchemy import or_
@@ -9,13 +9,11 @@ from utils import log_event, create_notification, get_unread_count
import json import json
import os import os
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from datetime import datetime, timedelta from datetime import datetime
import secrets
import string
contacts_bp = Blueprint('contacts', __name__, url_prefix='/contacts') contacts_bp = Blueprint('contacts', __name__, url_prefix='/contacts')
UPLOAD_FOLDER = '/app/uploads/profile_pics' UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads', 'profile_pics')
if not os.path.exists(UPLOAD_FOLDER): if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER) os.makedirs(UPLOAD_FOLDER)
@@ -29,8 +27,8 @@ def inject_unread_notifications():
def admin_required(): def admin_required():
if not current_user.is_authenticated: if not current_user.is_authenticated:
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
if not (current_user.is_admin or current_user.is_manager): if not current_user.is_admin:
flash('You must be an admin or manager to access this page.', 'error') flash('You must be an admin to access this page.', 'error')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
@contacts_bp.route('/') @contacts_bp.route('/')
@@ -72,10 +70,8 @@ def contacts_list():
# Apply role filter # Apply role filter
if role == 'admin': if role == 'admin':
query = query.filter(User.is_admin == True) query = query.filter(User.is_admin == True)
elif role == 'manager':
query = query.filter(User.is_manager == True)
elif role == 'user': elif role == 'user':
query = query.filter(User.is_admin == False, User.is_manager == False) query = query.filter(User.is_admin == False)
# Order by creation date # Order by creation date
query = query.order_by(User.created_at.desc()) query = query.order_by(User.created_at.desc())
@@ -98,8 +94,9 @@ def new_contact():
form = UserForm() form = UserForm()
total_admins = User.query.filter_by(is_admin=True).count() total_admins = User.query.filter_by(is_admin=True).count()
if request.method == 'GET': if request.method == 'GET':
form.role.data = 'user' # Default to standard user form.is_admin.data = False # Ensure admin role is unchecked by default
elif request.method == 'POST': elif request.method == 'POST' and 'is_admin' not in request.form:
form.is_admin.data = False # Explicitly set to False if not present in POST
if form.validate_on_submit(): if form.validate_on_submit():
# Check if a user with this email already exists # Check if a user with this email already exists
existing_user = User.query.filter_by(email=form.email.data).first() existing_user = User.query.filter_by(email=form.email.data).first()
@@ -116,11 +113,7 @@ def new_contact():
file.save(file_path) file.save(file_path)
profile_picture = filename profile_picture = filename
# Generate a random password # Create new user account
alphabet = string.ascii_letters + string.digits + string.punctuation
random_password = ''.join(secrets.choice(alphabet) for _ in range(32))
# Create new user
user = User( user = User(
username=form.first_name.data, username=form.first_name.data,
last_name=form.last_name.data, last_name=form.last_name.data,
@@ -129,12 +122,27 @@ def new_contact():
company=form.company.data, company=form.company.data,
position=form.position.data, position=form.position.data,
notes=form.notes.data, notes=form.notes.data,
is_admin=(form.role.data == 'admin'), is_active=True, # Set default value
is_manager=(form.role.data == 'manager'), is_admin=form.is_admin.data,
profile_picture=profile_picture profile_picture=profile_picture
) )
user.set_password(random_password) user.set_password('changeme') # Set default password
db.session.add(user) db.session.add(user)
db.session.commit()
# Create notification for the new user
create_notification(
notif_type='account_created',
user_id=user.id,
sender_id=current_user.id, # Admin who created the account
details={
'message': 'Your DocuPulse account has been created by an administrator.',
'username': user.username,
'email': user.email,
'created_by': f"{current_user.username} {current_user.last_name}",
'timestamp': datetime.utcnow().isoformat()
}
)
# Log user creation event # Log user creation event
log_event( log_event(
@@ -145,13 +153,13 @@ def new_contact():
'user_id': user.id, 'user_id': user.id,
'user_name': f"{user.username} {user.last_name}", 'user_name': f"{user.username} {user.last_name}",
'email': user.email, 'email': user.email,
'role': form.role.data, 'is_admin': user.is_admin,
'method': 'admin_creation' 'method': 'admin_creation'
} }
) )
db.session.commit() db.session.commit()
flash('User created successfully! They will receive an email with a link to set up their password.', 'success') flash('User created successfully! They will need to change their password on first login.', 'success')
return redirect(url_for('contacts.contacts_list')) return redirect(url_for('contacts.contacts_list'))
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins) return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
@@ -253,13 +261,7 @@ def edit_contact(id):
form.company.data = user.company form.company.data = user.company
form.position.data = user.position form.position.data = user.position
form.notes.data = user.notes form.notes.data = user.notes
# Set role based on current permissions form.is_admin.data = user.is_admin
if user.is_admin:
form.role.data = 'admin'
elif user.is_manager:
form.role.data = 'manager'
else:
form.role.data = 'user'
if form.validate_on_submit(): if form.validate_on_submit():
# Handle profile picture removal # Handle profile picture removal
if 'remove_picture' in request.form: if 'remove_picture' in request.form:
@@ -287,10 +289,9 @@ def edit_contact(id):
user.profile_picture = filename user.profile_picture = filename
# Prevent removing admin from the last admin # Prevent removing admin from the last admin
if form.role.data != 'admin' and user.is_admin and total_admins <= 1: if not form.is_admin.data and user.is_admin and total_admins <= 1:
flash('There must be at least one admin user in the system.', 'error') flash('There must be at least one admin user in the system.', 'error')
return render_template('contacts/form.html', form=form, title='Edit User', total_admins=total_admins, user=user) return render_template('contacts/form.html', form=form, title='Edit User', total_admins=total_admins, user=user)
# Check if the new email is already used by another user # Check if the new email is already used by another user
if form.email.data != user.email: if form.email.data != user.email:
existing_user = User.query.filter_by(email=form.email.data).first() existing_user = User.query.filter_by(email=form.email.data).first()
@@ -305,7 +306,7 @@ def edit_contact(id):
'phone': user.phone, 'phone': user.phone,
'company': user.company, 'company': user.company,
'position': user.position, 'position': user.position,
'role': 'admin' if user.is_admin else 'manager' if user.is_manager else 'user' 'is_admin': user.is_admin
} }
user.username = form.first_name.data user.username = form.first_name.data
@@ -315,8 +316,7 @@ def edit_contact(id):
user.company = form.company.data user.company = form.company.data
user.position = form.position.data user.position = form.position.data
user.notes = form.notes.data user.notes = form.notes.data
user.is_admin = (form.role.data == 'admin') user.is_admin = form.is_admin.data
user.is_manager = (form.role.data == 'manager')
# Set password if provided # Set password if provided
password_changed = False password_changed = False
@@ -340,7 +340,6 @@ def edit_contact(id):
'phone': user.phone, 'phone': user.phone,
'company': user.company, 'company': user.company,
'position': user.position, 'position': user.position,
'role': form.role.data,
'password_changed': password_changed 'password_changed': password_changed
}, },
'timestamp': datetime.utcnow().isoformat() 'timestamp': datetime.utcnow().isoformat()
@@ -362,7 +361,7 @@ def edit_contact(id):
'phone': user.phone, 'phone': user.phone,
'company': user.company, 'company': user.company,
'position': user.position, 'position': user.position,
'role': form.role.data 'is_admin': user.is_admin
}, },
'password_changed': password_changed, 'password_changed': password_changed,
'method': 'admin_update' 'method': 'admin_update'
@@ -448,53 +447,3 @@ def toggle_active(id):
flash(f'User marked as {"active" if user.is_active else "inactive"}!', 'success') flash(f'User marked as {"active" if user.is_active else "inactive"}!', 'success')
return redirect(url_for('contacts.contacts_list')) return redirect(url_for('contacts.contacts_list'))
@contacts_bp.route('/<int:id>/resend-setup', methods=['POST'])
@login_required
@require_password_change
def resend_setup_link(id):
result = admin_required()
if result: return result
user = User.query.get_or_404(id)
# Create new password setup token
token = secrets.token_urlsafe(32)
setup_token = PasswordSetupToken(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=24)
)
db.session.add(setup_token)
# Create notification for the user
create_notification(
notif_type='account_created',
user_id=user.id,
sender_id=current_user.id,
details={
'message': 'A new password setup link has been sent to you.',
'username': user.username,
'email': user.email,
'created_by': f"{current_user.username} {current_user.last_name}",
'timestamp': datetime.utcnow().isoformat(),
'setup_link': url_for('auth.setup_password', token=token, _external=True)
}
)
# Log the event
log_event(
event_type='user_update',
details={
'user_id': user.id,
'user_name': f"{user.username} {user.last_name}",
'updated_by': current_user.id,
'updated_by_name': f"{current_user.username} {current_user.last_name}",
'update_type': 'password_setup_link_resend'
}
)
db.session.commit()
flash('Password setup link has been resent to the user.', 'success')
return redirect(url_for('contacts.contacts_list'))

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
from flask_login import login_required, current_user from flask_login import login_required, current_user
from models import db, Conversation, User, Message, MessageAttachment, DocuPulseSettings from models import db, Conversation, User, Message, MessageAttachment
from forms import ConversationForm from forms import ConversationForm
from routes.auth import require_password_change from routes.auth import require_password_change
from utils import log_event, create_notification, get_unread_count from utils import log_event, create_notification, get_unread_count
@@ -55,15 +55,14 @@ def conversations():
query = query.filter(Conversation.name.ilike(f'%{search}%')) query = query.filter(Conversation.name.ilike(f'%{search}%'))
conversations = query.order_by(Conversation.created_at.desc()).all() conversations = query.order_by(Conversation.created_at.desc()).all()
unread_count = get_unread_count(current_user.id) unread_count = get_unread_count(current_user.id)
usage_stats = DocuPulseSettings.get_usage_stats() return render_template('conversations/conversations.html', conversations=conversations, search=search, unread_notifications=unread_count)
return render_template('conversations/conversations.html', conversations=conversations, search=search, unread_notifications=unread_count, usage_stats=usage_stats)
@conversations_bp.route('/create', methods=['GET', 'POST']) @conversations_bp.route('/create', methods=['GET', 'POST'])
@login_required @login_required
@require_password_change @require_password_change
def create_conversation(): def create_conversation():
if not (current_user.is_admin or current_user.is_manager): if not current_user.is_admin:
flash('Only administrators and managers can create conversations.', 'error') flash('Only administrators can create conversations.', 'error')
return redirect(url_for('conversations.conversations')) return redirect(url_for('conversations.conversations'))
form = ConversationForm() form = ConversationForm()
@@ -149,8 +148,8 @@ def conversation(conversation_id):
# Query messages directly using the Message model # Query messages directly using the Message model
messages = Message.query.filter_by(conversation_id=conversation_id).order_by(Message.created_at.asc()).all() messages = Message.query.filter_by(conversation_id=conversation_id).order_by(Message.created_at.asc()).all()
# Get all users for member selection (needed for admin and manager) # Get all users for member selection (only needed for admin)
all_users = User.query.all() if (current_user.is_admin or current_user.is_manager) else None all_users = User.query.all() if current_user.is_admin else None
unread_count = get_unread_count(current_user.id) unread_count = get_unread_count(current_user.id)
return render_template('conversations/conversation.html', return render_template('conversations/conversation.html',
@@ -168,8 +167,8 @@ def conversation_members(conversation_id):
flash('You do not have access to this conversation.', 'error') flash('You do not have access to this conversation.', 'error')
return redirect(url_for('conversations.conversations')) return redirect(url_for('conversations.conversations'))
if not (current_user.is_admin or current_user.is_manager): if not current_user.is_admin:
flash('Only administrators and managers can manage conversation members.', 'error') flash('Only administrators can manage conversation members.', 'error')
return redirect(url_for('conversations.conversation', conversation_id=conversation_id)) return redirect(url_for('conversations.conversation', conversation_id=conversation_id))
available_users = User.query.filter(~User.id.in_([m.id for m in conversation.members])).all() available_users = User.query.filter(~User.id.in_([m.id for m in conversation.members])).all()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,98 +0,0 @@
from flask import Blueprint, render_template, redirect, url_for, request
from models import SiteSettings, HelpArticle, PricingPlan
import os
def init_public_routes(public_bp):
@public_bp.context_processor
def inject_site_settings():
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"""
return render_template('public/features.html')
@public_bp.route('/pricing')
def pricing():
"""Pricing page"""
return render_template('public/pricing.html')
@public_bp.route('/about')
def about():
"""About page"""
return render_template('public/about.html')
@public_bp.route('/careers')
def careers():
"""Careers page"""
return render_template('public/careers.html')
@public_bp.route('/press')
def press():
"""Press page"""
return render_template('public/press.html')
@public_bp.route('/help')
def help_center():
"""Help Center page"""
return render_template('public/help.html')
@public_bp.route('/help/articles')
def help_articles():
"""Display help articles by category"""
category = request.args.get('category', '')
# Get all published articles grouped by category
all_articles = HelpArticle.get_all_published()
categories = HelpArticle.get_categories()
# If a specific category is requested, filter to that category
if category and category in categories:
articles = HelpArticle.get_articles_by_category(category)
category_name = categories[category]
else:
articles = []
category_name = None
return render_template('public/help_articles.html',
articles=articles,
all_articles=all_articles,
categories=categories,
current_category=category,
category_name=category_name)
@public_bp.route('/contact')
def contact():
"""Contact page"""
return render_template('public/contact.html')
@public_bp.route('/status')
def status():
"""Status page"""
return render_template('public/status.html')
@public_bp.route('/security')
def security():
"""Security page"""
return render_template('public/security.html')
@public_bp.route('/privacy')
def privacy():
"""Privacy Policy page"""
return render_template('public/privacy.html')
@public_bp.route('/terms')
def terms():
"""Terms of Service page"""
return render_template('public/terms.html')
@public_bp.route('/cookies')
def cookies():
"""Cookie Policy page"""
return render_template('public/cookies.html')

View File

@@ -35,7 +35,7 @@ from utils import log_event, create_notification, get_unread_count
room_files_bp = Blueprint('room_files', __name__, url_prefix='/api/rooms') room_files_bp = Blueprint('room_files', __name__, url_prefix='/api/rooms')
# Root directory for storing room files # Root directory for storing room files
DATA_ROOT = '/app/uploads/rooms' # Updated to match Docker volume mount point DATA_ROOT = '/data/rooms' # This should be a Docker volume
# Set of allowed file extensions for upload # Set of allowed file extensions for upload
ALLOWED_EXTENSIONS = { ALLOWED_EXTENSIONS = {
@@ -217,6 +217,7 @@ def upload_room_file(room_id):
# If we are overwriting, delete the trashed file record # If we are overwriting, delete the trashed file record
db.session.delete(trashed_file) db.session.delete(trashed_file)
db.session.commit()
existing_file = None existing_file = None
file.save(file_path) file.save(file_path)
@@ -346,19 +347,6 @@ def delete_file(room_id, filename):
if not rf: if not rf:
return jsonify({'error': 'File not found'}), 404 return jsonify({'error': 'File not found'}), 404
# If it's a folder, mark all contained items as deleted
if rf.type == 'folder':
folder_path = os.path.join(rf.path, rf.name) if rf.path else rf.name
contained_items = RoomFile.query.filter(
RoomFile.room_id == room_id,
RoomFile.path.like(f"{folder_path}%")
).all()
for item in contained_items:
item.deleted = True
item.deleted_by = current_user.id
item.deleted_at = datetime.utcnow()
# Mark as deleted and record who deleted it and when # Mark as deleted and record who deleted it and when
rf.deleted = True rf.deleted = True
rf.deleted_by = current_user.id rf.deleted_by = current_user.id
@@ -1065,9 +1053,6 @@ def delete_permanent(room_id):
for item in contained_items: for item in contained_items:
db.session.delete(item) db.session.delete(item)
# Delete the database record
db.session.delete(rf)
log_event( log_event(
event_type='file_delete_permanent', event_type='file_delete_permanent',
details={ details={
@@ -1081,15 +1066,10 @@ def delete_permanent(room_id):
user_id=current_user.id user_id=current_user.id
) )
except Exception as e: except Exception as e:
print(f"Error deleting file {rf.name}: {str(e)}") print(f"Error deleting {rf.type} from storage: {e}")
continue
# Delete the database record
db.session.delete(rf)
# Commit all changes
try:
db.session.commit() db.session.commit()
except Exception as e:
print(f"Error committing changes: {str(e)}")
db.session.rollback()
return jsonify({'error': 'Failed to delete files'}), 500
return jsonify({'success': True}) return jsonify({'success': True})

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from models import db, Room, User, RoomMemberPermission, RoomFile, Notif, DocuPulseSettings from models import db, Room, User, RoomMemberPermission, RoomFile, Notif
from forms import RoomForm from forms import RoomForm
from routes.room_files import user_has_permission from routes.room_files import user_has_permission
from routes.auth import require_password_change from routes.auth import require_password_change
@@ -36,11 +36,7 @@ def rooms():
if search: if search:
query = query.filter(Room.name.ilike(f'%{search}%')) query = query.filter(Room.name.ilike(f'%{search}%'))
rooms = query.order_by(Room.created_at.desc()).all() rooms = query.order_by(Room.created_at.desc()).all()
return render_template('rooms/rooms.html', rooms=rooms, search=search)
# Get usage stats
usage_stats = DocuPulseSettings.get_usage_stats()
return render_template('rooms/rooms.html', rooms=rooms, search=search, usage_stats=usage_stats)
@rooms_bp.route('/create', methods=['GET', 'POST']) @rooms_bp.route('/create', methods=['GET', 'POST'])
@login_required @login_required
@@ -89,7 +85,7 @@ def create_room():
@require_password_change @require_password_change
def room(room_id): def room(room_id):
room = Room.query.get_or_404(room_id) room = Room.query.get_or_404(room_id)
# Admins always have access, managers need to be members # Admins always have access
if not current_user.is_admin: if not current_user.is_admin:
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
if not is_member: if not is_member:
@@ -120,15 +116,14 @@ def room(room_id):
@require_password_change @require_password_change
def room_members(room_id): def room_members(room_id):
room = Room.query.get_or_404(room_id) room = Room.query.get_or_404(room_id)
# Check if user is a member # Admins always have access
if not current_user.is_admin: if not current_user.is_admin:
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
if not is_member: if not is_member:
flash('You do not have access to this room.', 'error') flash('You do not have access to this room.', 'error')
return redirect(url_for('rooms.rooms')) return redirect(url_for('rooms.rooms'))
# Only admins and managers who are members can manage room members if not current_user.is_admin:
if not (current_user.is_admin or (current_user.is_manager and is_member)): flash('Only administrators can manage room members.', 'error')
flash('Only administrators and managers can manage room members.', 'error')
return redirect(url_for('rooms.room', room_id=room_id)) return redirect(url_for('rooms.room', room_id=room_id))
member_permissions = {p.user_id: p for p in room.member_permissions} member_permissions = {p.user_id: p for p in room.member_permissions}
available_users = User.query.filter(~User.id.in_(member_permissions.keys())).all() available_users = User.query.filter(~User.id.in_(member_permissions.keys())).all()
@@ -144,9 +139,8 @@ def add_member(room_id):
if not is_member: if not is_member:
flash('You do not have access to this room.', 'error') flash('You do not have access to this room.', 'error')
return redirect(url_for('rooms.rooms')) return redirect(url_for('rooms.rooms'))
# Only admins and managers who are members can manage room members if not current_user.is_admin:
if not (current_user.is_admin or (current_user.is_manager and is_member)): flash('Only administrators can manage room members.', 'error')
flash('Only administrators and managers can manage room members.', 'error')
return redirect(url_for('rooms.room', room_id=room_id)) return redirect(url_for('rooms.room', room_id=room_id))
user_id = request.form.get('user_id') user_id = request.form.get('user_id')
if not user_id: if not user_id:
@@ -217,30 +211,59 @@ def remove_member(room_id, user_id):
if not is_member: if not is_member:
flash('You do not have access to this room.', 'error') flash('You do not have access to this room.', 'error')
return redirect(url_for('rooms.rooms')) return redirect(url_for('rooms.rooms'))
# Only admins and managers who are members can manage room members if not current_user.is_admin:
if not (current_user.is_admin or (current_user.is_manager and is_member)): flash('Only administrators can manage room members.', 'error')
flash('Only administrators and managers can manage room members.', 'error')
return redirect(url_for('rooms.room', room_id=room_id)) return redirect(url_for('rooms.room', room_id=room_id))
if user_id == room.created_by: if user_id == room.created_by:
flash('Cannot remove the room creator.', 'error') flash('Cannot remove the room creator.', 'error')
else: else:
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first() perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
if perm: if not perm:
flash('User is not a member of this room.', 'error')
else:
user = User.query.get(user_id)
try:
# Create notification for the removed user
create_notification(
notif_type='room_invite_removed',
user_id=user_id,
sender_id=current_user.id,
details={
'message': f'You have been removed from room "{room.name}"',
'room_id': room_id,
'room_name': room.name,
'removed_by': f"{current_user.username} {current_user.last_name}",
'timestamp': datetime.utcnow().isoformat()
}
)
log_event(
event_type='room_member_remove',
details={
'room_id': room_id,
'room_name': room.name,
'removed_user': f"{user.username} {user.last_name}",
'removed_by': f"{current_user.username} {current_user.last_name}"
},
user_id=current_user.id
)
db.session.delete(perm) db.session.delete(perm)
db.session.commit() db.session.commit()
flash('Member has been removed from the room.', 'success') flash('User has been removed from the room.', 'success')
else: except Exception as e:
flash('Member not found.', 'error') db.session.rollback()
flash('An error occurred while removing the member.', 'error')
print(f"Error removing member: {str(e)}")
return redirect(url_for('rooms.room_members', room_id=room_id)) return redirect(url_for('rooms.room_members', room_id=room_id))
@rooms_bp.route('/<int:room_id>/members/<int:user_id>/permissions', methods=['POST']) @rooms_bp.route('/<int:room_id>/members/<int:user_id>/permissions', methods=['POST'])
@login_required @login_required
def update_member_permissions(room_id, user_id): def update_member_permissions(room_id, user_id):
room = Room.query.get_or_404(room_id) room = Room.query.get_or_404(room_id)
# Check if user is a member if not current_user.is_admin:
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None flash('Only administrators can update permissions.', 'error')
if not (current_user.is_admin or (current_user.is_manager and is_member)):
flash('Only administrators and managers can update permissions.', 'error')
return redirect(url_for('rooms.room_members', room_id=room_id)) return redirect(url_for('rooms.room_members', room_id=room_id))
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first() perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
if not perm: if not perm:
@@ -289,13 +312,11 @@ def update_member_permissions(room_id, user_id):
@rooms_bp.route('/<int:room_id>/edit', methods=['GET', 'POST']) @rooms_bp.route('/<int:room_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit_room(room_id): def edit_room(room_id):
room = Room.query.get_or_404(room_id) if not current_user.is_admin:
# Check if user is a member flash('Only administrators can edit rooms.', 'error')
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
if not (current_user.is_admin or (current_user.is_manager and is_member)):
flash('Only administrators and managers can edit rooms.', 'error')
return redirect(url_for('rooms.rooms')) return redirect(url_for('rooms.rooms'))
room = Room.query.get_or_404(room_id)
form = RoomForm() form = RoomForm()
if form.validate_on_submit(): if form.validate_on_submit():
@@ -333,20 +354,18 @@ def edit_room(room_id):
@rooms_bp.route('/<int:room_id>/delete', methods=['POST']) @rooms_bp.route('/<int:room_id>/delete', methods=['POST'])
@login_required @login_required
def delete_room(room_id): def delete_room(room_id):
room = Room.query.get_or_404(room_id) if not current_user.is_admin:
# Check if user is a member flash('Only administrators can delete rooms.', 'error')
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
if not (current_user.is_admin or (current_user.is_manager and is_member)):
flash('Only administrators and managers can delete rooms.', 'error')
return redirect(url_for('rooms.rooms')) return redirect(url_for('rooms.rooms'))
room = Room.query.get_or_404(room_id)
room_name = room.name room_name = room.name
try: try:
print(f"Attempting to delete room {room_id} ({room_name})") print(f"Attempting to delete room {room_id} ({room_name})")
# Delete physical files # Delete physical files
room_dir = os.path.join('/app/uploads/rooms', str(room_id)) room_dir = os.path.join('/data/rooms', str(room_id))
if os.path.exists(room_dir): if os.path.exists(room_dir):
shutil.rmtree(room_dir) shutil.rmtree(room_dir)
print(f"Deleted room directory: {room_dir}") print(f"Deleted room directory: {room_dir}")

View File

@@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""
Utility script to set version environment variables for local development.
This replaces the need for version.txt file creation.
"""
import os
import subprocess
import json
from datetime import datetime
def get_git_info():
"""Get current git commit hash and branch"""
try:
# Get current commit hash
commit_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'],
text=True, stderr=subprocess.DEVNULL).strip()
# Get current branch
branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
text=True, stderr=subprocess.DEVNULL).strip()
# Get latest tag
try:
latest_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'],
text=True, stderr=subprocess.DEVNULL).strip()
except subprocess.CalledProcessError:
latest_tag = 'unknown'
return {
'commit': commit_hash,
'branch': branch,
'tag': latest_tag
}
except (subprocess.CalledProcessError, FileNotFoundError):
return {
'commit': 'unknown',
'branch': 'unknown',
'tag': 'unknown'
}
def set_version_env():
"""Set version environment variables"""
git_info = get_git_info()
# Set environment variables
os.environ['APP_VERSION'] = git_info['tag']
os.environ['GIT_COMMIT'] = git_info['commit']
os.environ['GIT_BRANCH'] = git_info['branch']
os.environ['DEPLOYED_AT'] = datetime.utcnow().isoformat()
print("Version environment variables set:")
print(f"APP_VERSION: {os.environ['APP_VERSION']}")
print(f"GIT_COMMIT: {os.environ['GIT_COMMIT']}")
print(f"GIT_BRANCH: {os.environ['GIT_BRANCH']}")
print(f"DEPLOYED_AT: {os.environ['DEPLOYED_AT']}")
if __name__ == '__main__':
set_version_env()

View File

@@ -6,6 +6,12 @@ while ! nc -z db 5432; do
done done
echo "Database is ready!" echo "Database is ready!"
echo "Waiting for Redis..."
while ! nc -z redis 6379; do
sleep 0.1
done
echo "Redis is ready!"
echo "Running database migrations..." echo "Running database migrations..."
flask db upgrade flask db upgrade

View File

@@ -88,90 +88,10 @@ body {
padding: 2rem; padding: 2rem;
} }
/* Enhanced Card Styles */
.card { .card {
border: none; border: none;
border-radius: 10px; border-radius: 10px;
box-shadow: 0 2px 4px var(--shadow-color); box-shadow: 0 2px 4px var(--shadow-color);
background: var(--white);
position: relative;
overflow: hidden;
}
/* Hover effects only for dashboard and room/conversation cards */
body[data-page="dashboard"] .card,
body[data-page="rooms"] .card,
body[data-page="conversations"] .card {
transition: all 0.3s ease;
}
body[data-page="dashboard"] .card:hover,
body[data-page="rooms"] .card:hover,
body[data-page="conversations"] .card:hover {
box-shadow: 0 8px 16px var(--shadow-color);
}
body[data-page="dashboard"] .card::after,
body[data-page="rooms"] .card::after,
body[data-page="conversations"] .card::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--primary-opacity-15) 0%, var(--secondary-opacity-15) 100%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
body[data-page="dashboard"] .card:hover::after,
body[data-page="rooms"] .card:hover::after,
body[data-page="conversations"] .card:hover::after {
opacity: 1;
}
/* Override hover effects for file grid cards */
#fileGrid .card {
transform: none !important;
box-shadow: 0 2px 4px var(--shadow-color) !important;
}
#fileGrid .card:hover {
transform: none !important;
box-shadow: 0 2px 4px var(--shadow-color) !important;
}
#fileGrid .card::after {
display: none !important;
}
/* Custom Scrollbar Styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--primary-color);
border-radius: 4px;
transition: background 0.3s ease;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary-light);
}
/* Firefox Scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--primary-color) var(--bg-color);
} }
.btn-primary { .btn-primary {
@@ -188,9 +108,6 @@ body[data-page="conversations"] .card:hover::after {
transition: transform 0.2s; transition: transform 0.2s;
} }
.document-card:hover {
/* Remove hover effect from list group items */ transform: translateY(-5px);
.list-group-item-action:hover {
background-color: transparent !important;
color: inherit !important;
} }

Some files were not shown because too many files have changed in this diff Show More