Compare commits
159 Commits
4d38c8715e
...
0.12.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 81675af837 | |||
| 0a2cddf122 | |||
| 56d94a06ce | |||
| de3880e880 | |||
| 0466b11c71 | |||
| e519dc3a8b | |||
| ac9f002365 | |||
| 8de74827f2 | |||
| 81552bc5ec | |||
| 490bc05a9e | |||
| cc699506d3 | |||
| 84da2eb489 | |||
| a9a61c98f5 | |||
| 4678022c7b | |||
| ca2d2e6587 | |||
| 912f97490c | |||
| d7f5809771 | |||
| 782be6bd38 | |||
| 996adb4bce | |||
| 6412d9f01a | |||
| 875e20304b | |||
| fed00ff2a0 | |||
| 10560a01fb | |||
| 56e7f1be53 | |||
| f5168c27bf | |||
| 4cf9cca116 | |||
| af375a2b5c | |||
| 23a55e025c | |||
| 40b1a63cf5 | |||
| 033f82eb2b | |||
| 1370bef1f1 | |||
| 1a6741ec10 | |||
| 0b9005b481 | |||
| 7ec3027410 | |||
| 405cc83ba1 | |||
| 0bbdf0eaab | |||
| 0da5d9305d | |||
| 9fc09be7de | |||
| 3f8517ec7d | |||
| e85d91d1f4 | |||
| bb139a2b95 | |||
| c06dd6c578 | |||
| 3d4034d6b1 | |||
| f825bab894 | |||
| c9d1d7416b | |||
| e25c7660b0 | |||
| 843af814fd | |||
| cb19b8b21c | |||
| 95456651a6 | |||
| 57aebb8c9e | |||
| e486b8a83d | |||
| 2f6de65e5c | |||
| 7092167001 | |||
| efdb6d50c3 | |||
| 04448e34c2 | |||
| da75b4cd50 | |||
| 4e9a3fe139 | |||
| e469db9ba6 | |||
| 64569c3505 | |||
| 15f69f533a | |||
| 5d28bf31dd | |||
| 3f3dba8759 | |||
| 8fde46c157 | |||
| d87b3e5b02 | |||
| 6b87fd6fc1 | |||
| 68940e87f9 | |||
| f0115a70f9 | |||
| a801eb1eeb | |||
| 83c94acbac | |||
| 5c2c514825 | |||
| 04689797f7 | |||
| 468235662b | |||
| f5e6076123 | |||
| 0f1dc51949 | |||
| 583f1c9d32 | |||
| af4ffb8559 | |||
| a9130bbe61 | |||
| 7015a46f94 | |||
| b96d5e4487 | |||
| 5bb0667060 | |||
| 580289d3a1 | |||
| 5c3cce1556 | |||
| f2361b94ba | |||
| f71b461e29 | |||
| 326bd1bd72 | |||
| 8f76832f69 | |||
| 309b03956f | |||
| 176ab4a194 | |||
| e43718894b | |||
| c31f1bb59d | |||
| 2014c326b1 | |||
| 112a99ffcb | |||
| 7aa96119a9 | |||
| 53ac07a9ee | |||
| 3288824383 | |||
| 9194b48eb8 | |||
| 301a83b295 | |||
| 2521c319a0 | |||
| 522ea2d976 | |||
| 91469735d2 | |||
| 7a8005c263 | |||
| fc05fda666 | |||
| fd34dd20ca | |||
| a917822fb8 | |||
| 30142f83df | |||
| 1cbfab0c2f | |||
| 46b56369c2 | |||
| 2db476ce09 | |||
| c5c1f35c08 | |||
| 3e5285225d | |||
| e58bec3da0 | |||
| cde3cba527 | |||
| c0346efcc7 | |||
| acececf899 | |||
| 8509b0567b | |||
| d619283d09 | |||
| e4238d9fdb | |||
| d7c1305dae | |||
| f6abdb5c63 | |||
| 51cea567ca | |||
| 85b769f7dd | |||
| 996f7dca16 | |||
| eb2946510a | |||
| 99a76c540f | |||
| 56177b2811 | |||
| 57fa221d47 | |||
| f65265b4a5 | |||
| 0047cfbcd1 | |||
| 33f6e0386b | |||
| 164e8373a4 | |||
| 5834aec885 | |||
| ca32ee0de4 | |||
| ee5b2d9fd9 | |||
| d4ae0fe2d8 | |||
| 39cbff2234 | |||
| 6273866324 | |||
| a78f3c0786 | |||
| 97fde3388b | |||
| 6e5229c8ba | |||
| b9df790d1f | |||
| 71213b87a0 | |||
| 5746600340 | |||
| 905a056c87 | |||
| 41cdd5ec7f | |||
| 88c3bc1b5b | |||
| 0f9f9d1b73 | |||
| 3dc897518e | |||
| 79fa32d1dd | |||
| 6ae1ee3365 | |||
| add00d488c | |||
| 0a471792e1 | |||
| e948a9e55f | |||
| 7f97d90f04 | |||
| b580bb2db3 | |||
| 9dd4ac5863 | |||
| 27d4922ce8 | |||
| c1d4fe1c9a | |||
| 02e7710676 | |||
| cd16d34fe5 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -26,4 +26,7 @@ logs/
|
||||
*.log
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
coverage/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
39
Dockerfile
39
Dockerfile
@@ -4,8 +4,10 @@ FROM python:3.11-slim
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
postgresql-client \
|
||||
curl \
|
||||
netcat-traditional \
|
||||
dos2unix \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user
|
||||
@@ -21,37 +23,16 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create necessary directories and set permissions
|
||||
RUN mkdir -p /app/uploads /app/static/uploads && \
|
||||
chown -R celery:celery /app
|
||||
|
||||
# Create and set up startup script
|
||||
RUN echo '#!/bin/bash\n\
|
||||
echo "Waiting for database..."\n\
|
||||
while ! nc -z db 5432; do\n\
|
||||
sleep 0.1\n\
|
||||
done\n\
|
||||
echo "Database is ready!"\n\
|
||||
\n\
|
||||
echo "Waiting for Redis..."\n\
|
||||
while ! nc -z redis 6379; do\n\
|
||||
sleep 0.1\n\
|
||||
done\n\
|
||||
echo "Redis is ready!"\n\
|
||||
\n\
|
||||
echo "Running database migrations..."\n\
|
||||
flask db upgrade\n\
|
||||
\n\
|
||||
echo "Creating admin user..."\n\
|
||||
flask create-admin\n\
|
||||
\n\
|
||||
echo "Starting application..."\n\
|
||||
exec "$@"' > /app/start.sh && \
|
||||
chmod +x /app/start.sh && \
|
||||
chown celery:celery /app/start.sh
|
||||
# Convert line endings and set permissions
|
||||
RUN dos2unix /app/entrypoint.sh && \
|
||||
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
|
||||
USER celery
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["/app/start.sh"]
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
|
||||
53
README.md
53
README.md
@@ -10,8 +10,9 @@ DocuPulse is a powerful document management system designed to streamline docume
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (version 18 or higher)
|
||||
- npm or yarn
|
||||
- Python 3.11 or higher
|
||||
- PostgreSQL 13 or higher
|
||||
- Docker and Docker Compose (for containerized deployment)
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -23,18 +24,50 @@ cd docupulse
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
3. Set up environment variables:
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# Copy example environment file
|
||||
cp .env.example .env
|
||||
|
||||
# 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
|
||||
|
||||
- Document upload and management
|
||||
@@ -42,6 +75,8 @@ yarn dev
|
||||
- Secure document storage
|
||||
- User authentication and authorization
|
||||
- Document version control
|
||||
- Multi-tenant instance management
|
||||
- RESTful API
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
114
app.py
114
app.py
@@ -8,14 +8,22 @@ from flask_wtf.csrf import generate_csrf
|
||||
from routes.room_files import room_files_bp
|
||||
from routes.room_members import room_members_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
|
||||
import click
|
||||
from utils import timeago
|
||||
from extensions import db, login_manager, csrf
|
||||
from utils.email_templates import create_default_templates
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
from utils.asset_utils import get_asset_version
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
print("Environment variables after loading .env:")
|
||||
print(f"MASTER: {os.getenv('MASTER')}")
|
||||
print(f"ISMASTER: {os.getenv('ISMASTER')}")
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
@@ -26,6 +34,12 @@ def create_app():
|
||||
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['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
|
||||
db.init_app(app)
|
||||
@@ -47,6 +61,20 @@ def create_app():
|
||||
db.session.commit()
|
||||
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
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
@@ -56,16 +84,21 @@ def create_app():
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
try:
|
||||
# Check database connection
|
||||
db.session.execute('SELECT 1')
|
||||
# Check database connection with a timeout
|
||||
db.session.execute(text('SELECT 1'))
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'database': 'connected'
|
||||
'database': 'connected',
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}), 200
|
||||
except Exception as e:
|
||||
app.logger.error(f"Health check failed: {str(e)}")
|
||||
return jsonify({
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
'error': str(e),
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}), 500
|
||||
|
||||
# Initialize routes
|
||||
@@ -74,6 +107,8 @@ def create_app():
|
||||
app.register_blueprint(room_files_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(admin_api, url_prefix='/api/admin')
|
||||
app.register_blueprint(launch_api, url_prefix='/api/admin')
|
||||
|
||||
@app.cli.command("cleanup-trash")
|
||||
def cleanup_trash_command():
|
||||
@@ -81,6 +116,13 @@ def create_app():
|
||||
cleanup_trash()
|
||||
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")
|
||||
def create_admin():
|
||||
"""Create the default administrator user."""
|
||||
@@ -92,15 +134,20 @@ def create_app():
|
||||
admin = User(
|
||||
username='administrator',
|
||||
email='administrator@docupulse.com',
|
||||
last_name='None',
|
||||
company='docupulse',
|
||||
last_name='Administrator',
|
||||
company='DocuPulse',
|
||||
position='System Administrator',
|
||||
is_admin=True,
|
||||
is_active=True
|
||||
is_active=True,
|
||||
preferred_view='grid'
|
||||
)
|
||||
admin.set_password('changeme')
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
click.echo("Default administrator user created successfully.")
|
||||
click.echo("Admin credentials:")
|
||||
click.echo("Email: administrator@docupulse.com")
|
||||
click.echo("Password: changeme")
|
||||
|
||||
# Register custom filters
|
||||
app.jinja_env.filters['timeago'] = timeago
|
||||
@@ -110,6 +157,29 @@ def create_app():
|
||||
try:
|
||||
# Ensure database tables exist
|
||||
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()
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not create default templates: {e}")
|
||||
@@ -118,9 +188,37 @@ def 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>')
|
||||
def profile_pic(filename):
|
||||
return send_from_directory(os.path.join(os.getcwd(), 'uploads', 'profile_pics'), filename)
|
||||
return send_from_directory('/app/uploads/profile_pics', filename)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
@@ -1,59 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
from app import create_app
|
||||
from models import db, RoomFile, Room, RoomMemberPermission
|
||||
from sqlalchemy import text
|
||||
|
||||
app = create_app()
|
||||
|
||||
def clear_all_data():
|
||||
with app.app_context():
|
||||
# Delete records in the correct order to handle foreign key constraints
|
||||
# 1. Delete all RoomFile records from the database
|
||||
RoomFile.query.delete()
|
||||
print("All RoomFile records deleted.")
|
||||
|
||||
# 2. Delete all RoomMemberPermission records
|
||||
RoomMemberPermission.query.delete()
|
||||
print("All RoomMemberPermission records deleted.")
|
||||
|
||||
# 3. Delete all room_members associations
|
||||
db.session.execute(text('DELETE FROM room_members'))
|
||||
print("All room_members associations deleted.")
|
||||
|
||||
# 4. Delete all Room records
|
||||
Room.query.delete()
|
||||
print("All Room records deleted.")
|
||||
|
||||
# Commit the database changes
|
||||
db.session.commit()
|
||||
print("Database cleanup completed.")
|
||||
|
||||
def clear_filesystem():
|
||||
# 1. Clear the data/rooms directory
|
||||
data_root = os.path.join(os.path.dirname(__file__), 'data', 'rooms')
|
||||
if os.path.exists(data_root):
|
||||
for item in os.listdir(data_root):
|
||||
item_path = os.path.join(data_root, item)
|
||||
if os.path.isfile(item_path):
|
||||
os.remove(item_path)
|
||||
elif os.path.isdir(item_path):
|
||||
shutil.rmtree(item_path)
|
||||
print("Cleared data/rooms directory")
|
||||
|
||||
# 2. Clear the uploads directory except for profile_pics
|
||||
uploads_dir = os.path.join(os.path.dirname(__file__), 'uploads')
|
||||
if os.path.exists(uploads_dir):
|
||||
for item in os.listdir(uploads_dir):
|
||||
if item != 'profile_pics':
|
||||
item_path = os.path.join(uploads_dir, item)
|
||||
if os.path.isfile(item_path):
|
||||
os.remove(item_path)
|
||||
elif os.path.isdir(item_path):
|
||||
shutil.rmtree(item_path)
|
||||
print("Cleared uploads directory")
|
||||
|
||||
if __name__ == '__main__':
|
||||
clear_all_data()
|
||||
clear_filesystem()
|
||||
print("Cleanup completed successfully!")
|
||||
@@ -1,27 +0,0 @@
|
||||
from app import create_app, db
|
||||
from app.models import RoomFile, Room
|
||||
import os
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
# Get the Test room
|
||||
room = Room.query.filter_by(name='Test').first()
|
||||
if not room:
|
||||
print("Test room not found")
|
||||
exit(1)
|
||||
|
||||
# Delete from database
|
||||
files = ['Screenshot_2025-03-19_100338.png', 'Screenshot_2025-03-19_100419.png']
|
||||
deleted = RoomFile.query.filter_by(room_id=room.id, name__in=files).delete()
|
||||
db.session.commit()
|
||||
print(f"Deleted {deleted} records from database")
|
||||
|
||||
# Delete from filesystem
|
||||
room_path = os.path.join('data', 'rooms', str(room.id))
|
||||
for file in files:
|
||||
file_path = os.path.join(room_path, file)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
print(f"Deleted file: {file_path}")
|
||||
else:
|
||||
print(f"File not found: {file_path}")
|
||||
@@ -1,11 +0,0 @@
|
||||
from app import app, db
|
||||
from models import Notif
|
||||
|
||||
def create_notifs_table():
|
||||
with app.app_context():
|
||||
# Create the table
|
||||
Notif.__table__.create(db.engine)
|
||||
print("Notifications table created successfully!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_notifs_table()
|
||||
@@ -1,18 +1,37 @@
|
||||
version: '3.8'
|
||||
|
||||
networks:
|
||||
docupulse_network:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
command: gunicorn --bind 0.0.0.0:5000 app:app
|
||||
build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
context: https://git.kobeamerijckx.com/Kobe/docupulse.git
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "10335:5000"
|
||||
- "${PORT:-10335}:5000"
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=production
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/docupulse
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=docupulse
|
||||
- DATABASE_URL=postgresql://docupulse_${PORT:-10335}:docupulse_${PORT:-10335}@db:5432/docupulse_${PORT:-10335}
|
||||
- POSTGRES_USER=docupulse_${PORT:-10335}
|
||||
- POSTGRES_PASSWORD=docupulse_${PORT:-10335}
|
||||
- POSTGRES_DB=docupulse_${PORT:-10335}
|
||||
- MASTER=${ISMASTER:-false}
|
||||
- APP_VERSION=${APP_VERSION:-unknown}
|
||||
- GIT_COMMIT=${GIT_COMMIT:-unknown}
|
||||
- GIT_BRANCH=${GIT_BRANCH:-unknown}
|
||||
- DEPLOYED_AT=${DEPLOYED_AT:-unknown}
|
||||
- PRICING_TIER_ID=${PRICING_TIER_ID:-0}
|
||||
- PRICING_TIER_NAME=${PRICING_TIER_NAME:-Unknown}
|
||||
- ROOM_QUOTA=${ROOM_QUOTA:-0}
|
||||
- CONVERSATION_QUOTA=${CONVERSATION_QUOTA:-0}
|
||||
- STORAGE_QUOTA_GB=${STORAGE_QUOTA_GB:-0}
|
||||
- MANAGER_QUOTA=${MANAGER_QUOTA:-0}
|
||||
- ADMIN_QUOTA=${ADMIN_QUOTA:-0}
|
||||
volumes:
|
||||
- docupulse_uploads:/app/uploads
|
||||
depends_on:
|
||||
@@ -20,42 +39,37 @@ services:
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
interval: 60s
|
||||
timeout: 30s
|
||||
retries: 3
|
||||
start_period: 120s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
networks:
|
||||
- docupulse_network
|
||||
|
||||
db:
|
||||
image: postgres:13
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=docupulse
|
||||
- POSTGRES_USER=docupulse_${PORT:-10335}
|
||||
- POSTGRES_PASSWORD=docupulse_${PORT:-10335}
|
||||
- POSTGRES_DB=docupulse_${PORT:-10335}
|
||||
volumes:
|
||||
- docupulse_postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
test: ["CMD-SHELL", "pg_isready -U docupulse_${PORT:-10335}"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- docupulse_network
|
||||
|
||||
volumes:
|
||||
docupulse_postgres_data:
|
||||
name: docupulse_${COMPOSE_PROJECT_NAME:-default}_postgres_data
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
device: /var/lib/docupulse/postgres/${COMPOSE_PROJECT_NAME:-default}
|
||||
o: bind
|
||||
name: docupulse_${PORT:-10335}_postgres_data
|
||||
docupulse_uploads:
|
||||
name: docupulse_${COMPOSE_PROJECT_NAME:-default}_uploads
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
device: /var/lib/docupulse/uploads/${COMPOSE_PROJECT_NAME:-default}
|
||||
o: bind
|
||||
name: docupulse_${PORT:-10335}_uploads
|
||||
174
entrypoint.sh
174
entrypoint.sh
@@ -7,12 +7,28 @@ echo "POSTGRES_PASSWORD: $POSTGRES_PASSWORD"
|
||||
echo "POSTGRES_DB: $POSTGRES_DB"
|
||||
echo "DATABASE_URL: $DATABASE_URL"
|
||||
|
||||
# Wait for the database to be ready
|
||||
echo "Waiting for database to be ready..."
|
||||
while ! nc -z db 5432; do
|
||||
sleep 0.1
|
||||
done
|
||||
echo "Database is ready!"
|
||||
# Function to wait for database
|
||||
wait_for_db() {
|
||||
echo "Waiting for database..."
|
||||
while ! nc -z db 5432; do
|
||||
sleep 1
|
||||
done
|
||||
echo "Database is ready!"
|
||||
}
|
||||
|
||||
# Function to create database if it doesn't exist
|
||||
create_database() {
|
||||
echo "Creating database if it doesn't exist..."
|
||||
PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -tc "SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB'" | grep -q 1 || \
|
||||
PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -c "CREATE DATABASE $POSTGRES_DB"
|
||||
echo "Database check/creation complete!"
|
||||
}
|
||||
|
||||
# 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
|
||||
echo "Waiting for PostgreSQL to accept connections..."
|
||||
@@ -22,60 +38,112 @@ until PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB
|
||||
done
|
||||
echo "PostgreSQL is up - executing command"
|
||||
|
||||
# Clean up existing migrations and initialize fresh
|
||||
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..."
|
||||
# Run all initialization in a single Python script to avoid multiple Flask instances
|
||||
echo "Running initialization..."
|
||||
python3 -c "
|
||||
from migrations.add_events_table import upgrade
|
||||
import sys
|
||||
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()
|
||||
with app.app_context():
|
||||
try:
|
||||
# Run migrations
|
||||
print('Running database migrations...')
|
||||
from flask_migrate import upgrade
|
||||
upgrade()
|
||||
print('Events table created successfully')
|
||||
print('Database migrations completed successfully')
|
||||
|
||||
# Create default site settings
|
||||
print('Creating default site settings...')
|
||||
try:
|
||||
settings = SiteSettings.get_settings()
|
||||
print('Default site settings created successfully')
|
||||
except Exception as e:
|
||||
log_error('Error creating site settings', e)
|
||||
|
||||
# Create admin user if it doesn't exist
|
||||
print('Creating admin user...')
|
||||
try:
|
||||
# Check for admin user by both username and email to avoid constraint violations
|
||||
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 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:
|
||||
print(f'Error creating events table: {e}')
|
||||
"
|
||||
|
||||
# Create notifs table
|
||||
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:
|
||||
settings = SiteSettings.get_settings()
|
||||
print('Default site settings created successfully')
|
||||
except Exception as e:
|
||||
print(f'Error creating site settings: {e}')
|
||||
"
|
||||
|
||||
# Initialize admin user
|
||||
echo "Initializing admin user..."
|
||||
python3 -c "
|
||||
from init_admin import init_admin
|
||||
init_admin()
|
||||
log_error('Fatal error during initialization', e)
|
||||
sys.exit(1)
|
||||
"
|
||||
|
||||
# Start the application
|
||||
|
||||
13
forms.py
13
forms.py
@@ -1,5 +1,5 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField, SelectMultipleField
|
||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField, SelectMultipleField, SelectField
|
||||
from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError
|
||||
from models import User
|
||||
from flask_login import current_user
|
||||
@@ -13,7 +13,11 @@ class UserForm(FlaskForm):
|
||||
company = StringField('Company (Optional)', validators=[Optional(), Length(max=100)])
|
||||
position = StringField('Position (Optional)', validators=[Optional(), Length(max=100)])
|
||||
notes = TextAreaField('Notes (Optional)', validators=[Optional()])
|
||||
is_admin = BooleanField('Admin Role', default=False)
|
||||
role = SelectField('Role', choices=[
|
||||
('user', 'Standard User'),
|
||||
('manager', 'Manager'),
|
||||
('admin', 'Administrator')
|
||||
], validators=[DataRequired()])
|
||||
new_password = PasswordField('New Password (Optional)')
|
||||
confirm_password = PasswordField('Confirm Password (Optional)')
|
||||
profile_picture = FileField('Profile Picture (Optional)', validators=[FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!')])
|
||||
@@ -30,6 +34,11 @@ class UserForm(FlaskForm):
|
||||
if total_admins <= 1:
|
||||
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):
|
||||
rv = super().validate(extra_validators=extra_validators)
|
||||
if not rv:
|
||||
|
||||
170
init_pricing_plans.py
Normal file
170
init_pricing_plans.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to initialize default pricing plans in the database.
|
||||
This should be run on a MASTER instance only.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from app import app, db
|
||||
from models import PricingPlan, User
|
||||
|
||||
def init_pricing_plans():
|
||||
"""Initialize default pricing plans"""
|
||||
|
||||
# Check if this is a MASTER instance
|
||||
if os.environ.get('MASTER', 'false').lower() != 'true':
|
||||
print("Error: This script should only be run on a MASTER instance.")
|
||||
print("Set MASTER=true environment variable to run this script.")
|
||||
sys.exit(1)
|
||||
|
||||
with app.app_context():
|
||||
# Check if pricing plans already exist
|
||||
existing_plans = PricingPlan.query.count()
|
||||
if existing_plans > 0:
|
||||
print(f"Found {existing_plans} existing pricing plans. Skipping initialization.")
|
||||
return
|
||||
|
||||
# Get the first admin user
|
||||
admin_user = User.query.filter_by(is_admin=True).first()
|
||||
if not admin_user:
|
||||
print("Error: No admin user found. Please create an admin user first.")
|
||||
sys.exit(1)
|
||||
|
||||
# Default pricing plans
|
||||
default_plans = [
|
||||
{
|
||||
'name': 'Starter',
|
||||
'description': 'Perfect for small teams getting started',
|
||||
'monthly_price': 29.0,
|
||||
'annual_price': 23.0,
|
||||
'features': [
|
||||
'Up to 5 rooms',
|
||||
'Up to 10 conversations',
|
||||
'10GB storage',
|
||||
'Up to 10 managers',
|
||||
'Email support'
|
||||
],
|
||||
'button_text': 'Get Started',
|
||||
'button_url': '#',
|
||||
'is_popular': False,
|
||||
'is_custom': False,
|
||||
'is_active': True,
|
||||
'order_index': 1,
|
||||
'room_quota': 5,
|
||||
'conversation_quota': 10,
|
||||
'storage_quota_gb': 10,
|
||||
'manager_quota': 10,
|
||||
'admin_quota': 1
|
||||
},
|
||||
{
|
||||
'name': 'Professional',
|
||||
'description': 'Ideal for growing businesses',
|
||||
'monthly_price': 99.0,
|
||||
'annual_price': 79.0,
|
||||
'features': [
|
||||
'Up to 25 rooms',
|
||||
'Up to 50 conversations',
|
||||
'100GB storage',
|
||||
'Up to 50 managers',
|
||||
'Priority support'
|
||||
],
|
||||
'button_text': 'Get Started',
|
||||
'button_url': '#',
|
||||
'is_popular': True,
|
||||
'is_custom': False,
|
||||
'is_active': True,
|
||||
'order_index': 2,
|
||||
'room_quota': 25,
|
||||
'conversation_quota': 50,
|
||||
'storage_quota_gb': 100,
|
||||
'manager_quota': 50,
|
||||
'admin_quota': 3
|
||||
},
|
||||
{
|
||||
'name': 'Enterprise',
|
||||
'description': 'For large organizations with advanced needs',
|
||||
'monthly_price': 299.0,
|
||||
'annual_price': 239.0,
|
||||
'features': [
|
||||
'Up to 100 rooms',
|
||||
'Up to 200 conversations',
|
||||
'500GB storage',
|
||||
'Up to 200 managers',
|
||||
'24/7 dedicated support'
|
||||
],
|
||||
'button_text': 'Get Started',
|
||||
'button_url': '#',
|
||||
'is_popular': False,
|
||||
'is_custom': False,
|
||||
'is_active': True,
|
||||
'order_index': 3,
|
||||
'room_quota': 100,
|
||||
'conversation_quota': 200,
|
||||
'storage_quota_gb': 500,
|
||||
'manager_quota': 200,
|
||||
'admin_quota': 10
|
||||
},
|
||||
{
|
||||
'name': 'Custom',
|
||||
'description': 'Tailored solutions for enterprise customers',
|
||||
'monthly_price': 0.0,
|
||||
'annual_price': 0.0,
|
||||
'features': [
|
||||
'Unlimited rooms',
|
||||
'Unlimited conversations',
|
||||
'Unlimited storage',
|
||||
'Unlimited users',
|
||||
'Custom integrations',
|
||||
'Dedicated account manager'
|
||||
],
|
||||
'button_text': 'Contact Sales',
|
||||
'button_url': '#',
|
||||
'is_popular': False,
|
||||
'is_custom': True,
|
||||
'is_active': True,
|
||||
'order_index': 4,
|
||||
'room_quota': 0,
|
||||
'conversation_quota': 0,
|
||||
'storage_quota_gb': 0,
|
||||
'manager_quota': 0,
|
||||
'admin_quota': 0
|
||||
}
|
||||
]
|
||||
|
||||
# Create pricing plans
|
||||
for plan_data in default_plans:
|
||||
plan = PricingPlan(
|
||||
name=plan_data['name'],
|
||||
description=plan_data['description'],
|
||||
monthly_price=plan_data['monthly_price'],
|
||||
annual_price=plan_data['annual_price'],
|
||||
features=plan_data['features'],
|
||||
button_text=plan_data['button_text'],
|
||||
button_url=plan_data['button_url'],
|
||||
is_popular=plan_data['is_popular'],
|
||||
is_custom=plan_data['is_custom'],
|
||||
is_active=plan_data['is_active'],
|
||||
order_index=plan_data['order_index'],
|
||||
room_quota=plan_data['room_quota'],
|
||||
conversation_quota=plan_data['conversation_quota'],
|
||||
storage_quota_gb=plan_data['storage_quota_gb'],
|
||||
manager_quota=plan_data['manager_quota'],
|
||||
admin_quota=plan_data['admin_quota'],
|
||||
created_by=admin_user.id
|
||||
)
|
||||
db.session.add(plan)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
print("Successfully created default pricing plans:")
|
||||
for plan_data in default_plans:
|
||||
print(f" - {plan_data['name']}: €{plan_data['monthly_price']}/month")
|
||||
print("\nYou can now configure these plans in the admin settings.")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error creating pricing plans: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_pricing_plans()
|
||||
@@ -1,61 +0,0 @@
|
||||
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()
|
||||
@@ -1,61 +0,0 @@
|
||||
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()
|
||||
24
migrations/versions/4ee23cb29001_merge_heads.py
Normal file
24
migrations/versions/4ee23cb29001_merge_heads.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""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
|
||||
32
migrations/versions/72ab6c4c6a5f_merge_heads.py
Normal file
32
migrations/versions/72ab6c4c6a5f_merge_heads.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""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
|
||||
24
migrations/versions/761908f0cacf_merge_heads.py
Normal file
24
migrations/versions/761908f0cacf_merge_heads.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""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
|
||||
@@ -0,0 +1,46 @@
|
||||
"""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 ###
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
24
migrations/versions/a1fd98e6630d_merge_heads.py
Normal file
24
migrations/versions/a1fd98e6630d_merge_heads.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""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
|
||||
31
migrations/versions/add_connection_token.py
Normal file
31
migrations/versions/add_connection_token.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""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')
|
||||
56
migrations/versions/add_docupulse_settings_table.py
Normal file
56
migrations/versions/add_docupulse_settings_table.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""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')
|
||||
42
migrations/versions/add_events_table.py
Normal file
42
migrations/versions/add_events_table.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""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')
|
||||
56
migrations/versions/add_help_articles_table.py
Normal file
56
migrations/versions/add_help_articles_table.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""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')
|
||||
69
migrations/versions/add_instances_table.py
Normal file
69
migrations/versions/add_instances_table.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""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')
|
||||
38
migrations/versions/add_manager_role.py
Normal file
38
migrations/versions/add_manager_role.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""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 ###
|
||||
46
migrations/versions/add_notifs_table.py
Normal file
46
migrations/versions/add_notifs_table.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""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')
|
||||
44
migrations/versions/add_password_reset_tokens_table.py
Normal file
44
migrations/versions/add_password_reset_tokens_table.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""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')
|
||||
62
migrations/versions/add_pricing_plans_table.py
Normal file
62
migrations/versions/add_pricing_plans_table.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""add pricing plans table
|
||||
|
||||
Revision ID: add_pricing_plans_table
|
||||
Revises: add_help_articles_table
|
||||
Create Date: 2024-12-19 11:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_pricing_plans_table'
|
||||
down_revision = 'add_help_articles_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(text("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'pricing_plans'
|
||||
);
|
||||
"""))
|
||||
exists = result.scalar()
|
||||
if not exists:
|
||||
op.create_table('pricing_plans',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('monthly_price', sa.Float(), nullable=False),
|
||||
sa.Column('annual_price', sa.Float(), nullable=False),
|
||||
sa.Column('features', sa.JSON(), nullable=False),
|
||||
sa.Column('is_popular', sa.Boolean(), nullable=True, server_default='false'),
|
||||
sa.Column('is_custom', sa.Boolean(), nullable=True, server_default='false'),
|
||||
sa.Column('button_text', sa.String(length=50), nullable=True, server_default="'Get Started'"),
|
||||
sa.Column('button_url', sa.String(length=200), nullable=True, server_default="'#'"),
|
||||
sa.Column('order_index', sa.Integer(), nullable=True, server_default='0'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for better performance
|
||||
op.create_index('idx_pricing_plans_active', 'pricing_plans', ['is_active'])
|
||||
op.create_index('idx_pricing_plans_order', 'pricing_plans', ['order_index'])
|
||||
op.create_index('idx_pricing_plans_popular', 'pricing_plans', ['is_popular'])
|
||||
op.create_index('idx_pricing_plans_created_at', 'pricing_plans', ['created_at'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('idx_pricing_plans_active', table_name='pricing_plans')
|
||||
op.drop_index('idx_pricing_plans_order', table_name='pricing_plans')
|
||||
op.drop_index('idx_pricing_plans_popular', table_name='pricing_plans')
|
||||
op.drop_index('idx_pricing_plans_created_at', table_name='pricing_plans')
|
||||
op.drop_table('pricing_plans')
|
||||
55
migrations/versions/add_quota_fields_to_pricing_plans.py
Normal file
55
migrations/versions/add_quota_fields_to_pricing_plans.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""add quota fields to pricing plans
|
||||
|
||||
Revision ID: add_quota_fields
|
||||
Revises: add_pricing_plans_table
|
||||
Create Date: 2024-12-19 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_quota_fields'
|
||||
down_revision = 'add_pricing_plans_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
# Check if columns already exist
|
||||
result = conn.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'pricing_plans'
|
||||
AND column_name IN ('room_quota', 'conversation_quota', 'storage_quota_gb', 'manager_quota', 'admin_quota')
|
||||
"""))
|
||||
existing_columns = [row[0] for row in result.fetchall()]
|
||||
|
||||
# Add quota columns if they don't exist
|
||||
if 'room_quota' not in existing_columns:
|
||||
op.add_column('pricing_plans', sa.Column('room_quota', sa.Integer(), nullable=True, server_default='0'))
|
||||
|
||||
if 'conversation_quota' not in existing_columns:
|
||||
op.add_column('pricing_plans', sa.Column('conversation_quota', sa.Integer(), nullable=True, server_default='0'))
|
||||
|
||||
if 'storage_quota_gb' not in existing_columns:
|
||||
op.add_column('pricing_plans', sa.Column('storage_quota_gb', sa.Integer(), nullable=True, server_default='0'))
|
||||
|
||||
if 'manager_quota' not in existing_columns:
|
||||
op.add_column('pricing_plans', sa.Column('manager_quota', sa.Integer(), nullable=True, server_default='0'))
|
||||
|
||||
if 'admin_quota' not in existing_columns:
|
||||
op.add_column('pricing_plans', sa.Column('admin_quota', sa.Integer(), nullable=True, server_default='0'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Remove quota columns
|
||||
op.drop_column('pricing_plans', 'admin_quota')
|
||||
op.drop_column('pricing_plans', 'manager_quota')
|
||||
op.drop_column('pricing_plans', 'storage_quota_gb')
|
||||
op.drop_column('pricing_plans', 'conversation_quota')
|
||||
op.drop_column('pricing_plans', 'room_quota')
|
||||
36
migrations/versions/add_status_details.py
Normal file
36
migrations/versions/add_status_details.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,87 @@
|
||||
"""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 ###
|
||||
45
migrations/versions/fix_updated_at_trigger.py
Normal file
45
migrations/versions/fix_updated_at_trigger.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""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()")
|
||||
27
migrations/versions/make_events_user_id_nullable.py
Normal file
27
migrations/versions/make_events_user_id_nullable.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""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)
|
||||
366
models.py
366
models.py
@@ -22,10 +22,11 @@ conversation_members = db.Table('conversation_members',
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||
last_name = db.Column(db.String(150), nullable=False, default='(You)')
|
||||
last_name = db.Column(db.String(150), nullable=False, default='--')
|
||||
email = db.Column(db.String(150), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(256))
|
||||
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)
|
||||
phone = db.Column(db.String(20))
|
||||
company = db.Column(db.String(100))
|
||||
@@ -34,7 +35,11 @@ class User(UserMixin, db.Model):
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
profile_picture = db.Column(db.String(255))
|
||||
preferred_view = db.Column(db.String(10), default='grid', nullable=False) # 'grid' or 'list'
|
||||
room_permissions = relationship('RoomMemberPermission', back_populates='user')
|
||||
room_permissions = relationship(
|
||||
'RoomMemberPermission',
|
||||
back_populates='user',
|
||||
cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
@@ -50,10 +55,10 @@ class Room(db.Model):
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
creator = db.relationship('User', backref='created_rooms', foreign_keys=[created_by])
|
||||
creator = db.relationship('User', backref=db.backref('created_rooms', cascade='all, delete-orphan'), foreign_keys=[created_by])
|
||||
members = db.relationship('User', secondary=room_members, backref=db.backref('rooms', lazy='dynamic'))
|
||||
member_permissions = relationship('RoomMemberPermission', back_populates='room', cascade='all, delete-orphan')
|
||||
files = db.relationship('RoomFile', back_populates='room', cascade='all, delete-orphan')
|
||||
@@ -65,7 +70,7 @@ class Room(db.Model):
|
||||
class RoomMemberPermission(db.Model):
|
||||
__tablename__ = 'room_member_permissions'
|
||||
room_id = db.Column(db.Integer, db.ForeignKey('room.id'), primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), primary_key=True)
|
||||
can_view = db.Column(db.Boolean, default=True, nullable=False)
|
||||
can_download = db.Column(db.Boolean, default=False, nullable=False)
|
||||
can_upload = db.Column(db.Boolean, default=False, nullable=False)
|
||||
@@ -86,13 +91,13 @@ class RoomFile(db.Model):
|
||||
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
|
||||
size = db.Column(db.Integer) # in bytes, null for folders
|
||||
modified = db.Column(db.Float) # timestamp
|
||||
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'))
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
deleted = db.Column(db.Boolean, default=False) # New field for deleted status
|
||||
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id')) # New field for tracking who deleted the file
|
||||
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'))
|
||||
deleted_at = db.Column(db.DateTime) # New field for tracking when the file was deleted
|
||||
uploader = db.relationship('User', backref='uploaded_files', foreign_keys=[uploaded_by])
|
||||
deleter = db.relationship('User', backref='deleted_room_files', foreign_keys=[deleted_by])
|
||||
uploader = db.relationship('User', backref=db.backref('uploaded_files', cascade='all, delete-orphan'), foreign_keys=[uploaded_by])
|
||||
deleter = db.relationship('User', backref=db.backref('deleted_room_files', cascade='all, delete-orphan'), foreign_keys=[deleted_by])
|
||||
room = db.relationship('Room', back_populates='files')
|
||||
starred_by = db.relationship('User', secondary='user_starred_file', backref='starred_files')
|
||||
|
||||
@@ -102,7 +107,7 @@ class RoomFile(db.Model):
|
||||
class UserStarredFile(db.Model):
|
||||
__tablename__ = 'user_starred_file'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
||||
file_id = db.Column(db.Integer, db.ForeignKey('room_file.id'), nullable=False)
|
||||
starred_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -123,13 +128,13 @@ class TrashedFile(db.Model):
|
||||
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
|
||||
size = db.Column(db.Integer) # in bytes, null for folders
|
||||
modified = db.Column(db.Float) # timestamp
|
||||
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'))
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
||||
deleted_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
room = db.relationship('Room', backref='trashed_files')
|
||||
uploader = db.relationship('User', foreign_keys=[uploaded_by], backref='uploaded_trashed_files')
|
||||
deleter = db.relationship('User', foreign_keys=[deleted_by], backref='deleted_trashed_files') # Changed from deleted_files to deleted_trashed_files
|
||||
uploader = db.relationship('User', foreign_keys=[uploaded_by], backref=db.backref('uploaded_trashed_files', cascade='all, delete-orphan'))
|
||||
deleter = db.relationship('User', foreign_keys=[deleted_by], backref=db.backref('deleted_trashed_files', cascade='all, delete-orphan'))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<TrashedFile {self.name} ({self.type}) from {self.original_path}>'
|
||||
@@ -161,6 +166,65 @@ class SiteSettings(db.Model):
|
||||
db.session.commit()
|
||||
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):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(100), unique=True, nullable=False)
|
||||
@@ -197,10 +261,10 @@ class Conversation(db.Model):
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
creator = db.relationship('User', backref='created_conversations', foreign_keys=[created_by])
|
||||
creator = db.relationship('User', backref=db.backref('created_conversations', cascade='all, delete-orphan'), foreign_keys=[created_by])
|
||||
members = db.relationship('User', secondary=conversation_members, backref=db.backref('conversations', lazy='dynamic'))
|
||||
messages = db.relationship('Message', back_populates='conversation', cascade='all, delete-orphan')
|
||||
|
||||
@@ -212,11 +276,11 @@ class Message(db.Model):
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
conversation = db.relationship('Conversation', back_populates='messages')
|
||||
user = db.relationship('User', backref='messages')
|
||||
user = db.relationship('User', backref=db.backref('messages', cascade='all, delete-orphan'))
|
||||
attachments = db.relationship('MessageAttachment', back_populates='message', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
@@ -284,14 +348,14 @@ class Event(db.Model):
|
||||
__tablename__ = 'events'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_type = db.Column(db.String(50), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=True)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
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
|
||||
user_agent = db.Column(db.String(255))
|
||||
|
||||
# Relationships
|
||||
user = db.relationship('User', backref='events')
|
||||
user = db.relationship('User', backref=db.backref('events', cascade='all, delete-orphan'))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Event {self.event_type} by User {self.user_id} at {self.timestamp}>'
|
||||
@@ -316,14 +380,14 @@ class Notif(db.Model):
|
||||
__tablename__ = 'notifs'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
notif_type = db.Column(db.String(50), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
||||
sender_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=True)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
read = db.Column(db.Boolean, default=False, nullable=False)
|
||||
details = db.Column(db.JSON) # Store additional notification-specific data
|
||||
|
||||
# Relationships
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref='notifications')
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('notifications', cascade='all, delete-orphan'))
|
||||
sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_notifications')
|
||||
|
||||
def __repr__(self):
|
||||
@@ -337,11 +401,11 @@ class EmailTemplate(db.Model):
|
||||
body = db.Column(db.Text, nullable=False)
|
||||
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'), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
creator = db.relationship('User', backref='created_email_templates', foreign_keys=[created_by])
|
||||
creator = db.relationship('User', backref=db.backref('created_email_templates', cascade='all, delete-orphan'), foreign_keys=[created_by])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<EmailTemplate {self.name}>'
|
||||
@@ -363,4 +427,254 @@ class Mail(db.Model):
|
||||
notif = db.relationship('Notif', backref='mails')
|
||||
|
||||
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
|
||||
@@ -1,14 +1,16 @@
|
||||
Flask>=2.0.0
|
||||
Flask-SQLAlchemy>=3.0.0
|
||||
Flask-Login>=0.6.0
|
||||
Flask-WTF>=1.0.0
|
||||
Flask-Mail==0.9.1
|
||||
Flask-Migrate>=4.0.0
|
||||
SQLAlchemy>=1.4.0
|
||||
Werkzeug>=2.0.0
|
||||
WTForms==3.1.1
|
||||
Flask-WTF>=1.0.0
|
||||
email-validator==2.1.0.post1
|
||||
python-dotenv>=0.19.0
|
||||
psycopg2-binary==2.9.9
|
||||
gunicorn==21.2.0
|
||||
email_validator==2.1.0.post1
|
||||
Werkzeug>=2.0.0
|
||||
SQLAlchemy>=1.4.0
|
||||
alembic>=1.7.0
|
||||
prometheus-client>=0.16.0
|
||||
psycopg2-binary==2.9.9
|
||||
requests>=2.31.0
|
||||
gunicorn==21.2.0
|
||||
prometheus-client>=0.16.0
|
||||
PyJWT>=2.8.0
|
||||
@@ -1,12 +1,14 @@
|
||||
from flask import Blueprint, Flask, render_template
|
||||
from flask_login import login_required
|
||||
from models import SiteSettings
|
||||
import os
|
||||
|
||||
def init_app(app: Flask):
|
||||
# Create blueprints
|
||||
main_bp = Blueprint('main', __name__)
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
rooms_bp = Blueprint('rooms', __name__)
|
||||
public_bp = Blueprint('public', __name__)
|
||||
|
||||
# Import and initialize routes
|
||||
from .main import init_routes as init_main_routes
|
||||
@@ -17,10 +19,12 @@ def init_app(app: Flask):
|
||||
from .admin import admin as admin_routes
|
||||
from .email_templates import email_templates as email_templates_routes
|
||||
from .user import user_bp as user_routes
|
||||
from .public import init_public_routes
|
||||
|
||||
# Initialize routes
|
||||
init_main_routes(main_bp)
|
||||
init_auth_routes(auth_bp)
|
||||
init_public_routes(public_bp)
|
||||
|
||||
# Add site_settings context processor to all blueprints
|
||||
@app.context_processor
|
||||
@@ -28,9 +32,15 @@ def init_app(app: Flask):
|
||||
site_settings = SiteSettings.query.first()
|
||||
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
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(public_bp)
|
||||
app.register_blueprint(rooms_routes)
|
||||
app.register_blueprint(contacts_routes)
|
||||
app.register_blueprint(conversations_routes)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
503
routes/admin.py
503
routes/admin.py
@@ -1,8 +1,11 @@
|
||||
from flask import Blueprint, jsonify
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Room, RoomFile, User
|
||||
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle
|
||||
from extensions import csrf
|
||||
from utils.event_logger import log_event
|
||||
import os
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
admin = Blueprint('admin', __name__)
|
||||
|
||||
@@ -13,7 +16,7 @@ def sync_files():
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
try:
|
||||
DATA_ROOT = '/data/rooms'
|
||||
DATA_ROOT = '/app/uploads/rooms'
|
||||
admin_user = User.query.filter_by(is_admin=True).first()
|
||||
if not admin_user:
|
||||
return jsonify({'error': 'No admin user found'}), 500
|
||||
@@ -73,7 +76,7 @@ def verify_db_state():
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
try:
|
||||
DATA_ROOT = '/data/rooms'
|
||||
DATA_ROOT = '/app/uploads/rooms'
|
||||
verification_results = {
|
||||
'rooms_checked': 0,
|
||||
'files_in_db_not_fs': [],
|
||||
@@ -208,7 +211,7 @@ def cleanup_orphaned_records():
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
try:
|
||||
DATA_ROOT = '/data/rooms'
|
||||
DATA_ROOT = '/app/uploads/rooms'
|
||||
rooms = Room.query.all()
|
||||
cleaned_records = []
|
||||
|
||||
@@ -241,4 +244,492 @@ def cleanup_orphaned_records():
|
||||
})
|
||||
except Exception as e:
|
||||
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
|
||||
@csrf.exempt
|
||||
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
|
||||
@csrf.exempt
|
||||
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
|
||||
@csrf.exempt
|
||||
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
|
||||
@csrf.exempt
|
||||
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
|
||||
@csrf.exempt
|
||||
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
|
||||
598
routes/admin_api.py
Normal file
598
routes/admin_api.py
Normal file
@@ -0,0 +1,598 @@
|
||||
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
|
||||
|
||||
256
routes/auth.py
256
routes/auth.py
@@ -1,18 +1,31 @@
|
||||
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 models import db, User, Notif
|
||||
from models import db, User, Notif, PasswordSetupToken, PasswordResetToken
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
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__)
|
||||
|
||||
def require_password_change(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.is_authenticated and current_user.check_password('changeme'):
|
||||
flash('Please change your password before continuing.', 'warning')
|
||||
return redirect(url_for('auth.change_password'))
|
||||
if current_user.is_authenticated:
|
||||
# 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')
|
||||
return redirect(url_for('auth.change_password'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@@ -215,4 +228,235 @@ def init_routes(auth_bp):
|
||||
flash('Password changed successfully!', 'success')
|
||||
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)
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, User, Notif
|
||||
from models import db, User, Notif, PasswordSetupToken
|
||||
from forms import UserForm
|
||||
from flask import abort
|
||||
from sqlalchemy import or_
|
||||
@@ -9,11 +9,13 @@ from utils import log_event, create_notification, get_unread_count
|
||||
import json
|
||||
import os
|
||||
from werkzeug.utils import secure_filename
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import string
|
||||
|
||||
contacts_bp = Blueprint('contacts', __name__, url_prefix='/contacts')
|
||||
|
||||
UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads', 'profile_pics')
|
||||
UPLOAD_FOLDER = '/app/uploads/profile_pics'
|
||||
if not os.path.exists(UPLOAD_FOLDER):
|
||||
os.makedirs(UPLOAD_FOLDER)
|
||||
|
||||
@@ -27,8 +29,8 @@ def inject_unread_notifications():
|
||||
def admin_required():
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login'))
|
||||
if not current_user.is_admin:
|
||||
flash('You must be an admin to access this page.', 'error')
|
||||
if not (current_user.is_admin or current_user.is_manager):
|
||||
flash('You must be an admin or manager to access this page.', 'error')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
@contacts_bp.route('/')
|
||||
@@ -70,8 +72,10 @@ def contacts_list():
|
||||
# Apply role filter
|
||||
if role == 'admin':
|
||||
query = query.filter(User.is_admin == True)
|
||||
elif role == 'manager':
|
||||
query = query.filter(User.is_manager == True)
|
||||
elif role == 'user':
|
||||
query = query.filter(User.is_admin == False)
|
||||
query = query.filter(User.is_admin == False, User.is_manager == False)
|
||||
|
||||
# Order by creation date
|
||||
query = query.order_by(User.created_at.desc())
|
||||
@@ -94,73 +98,61 @@ def new_contact():
|
||||
form = UserForm()
|
||||
total_admins = User.query.filter_by(is_admin=True).count()
|
||||
if request.method == 'GET':
|
||||
form.is_admin.data = False # Ensure admin role is unchecked by default
|
||||
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():
|
||||
# Check if a user with this email already exists
|
||||
existing_user = User.query.filter_by(email=form.email.data).first()
|
||||
if existing_user:
|
||||
flash('A user with this email already exists.', 'error')
|
||||
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
|
||||
|
||||
# Handle profile picture upload
|
||||
profile_picture = None
|
||||
file = request.files.get('profile_picture')
|
||||
if file and file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
file.save(file_path)
|
||||
profile_picture = filename
|
||||
form.role.data = 'user' # Default to standard user
|
||||
elif request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
# Check if a user with this email already exists
|
||||
existing_user = User.query.filter_by(email=form.email.data).first()
|
||||
if existing_user:
|
||||
flash('A user with this email already exists.', 'error')
|
||||
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
|
||||
|
||||
# Handle profile picture upload
|
||||
profile_picture = None
|
||||
file = request.files.get('profile_picture')
|
||||
if file and file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
file.save(file_path)
|
||||
profile_picture = filename
|
||||
|
||||
# Create new user account
|
||||
user = User(
|
||||
username=form.first_name.data,
|
||||
last_name=form.last_name.data,
|
||||
email=form.email.data,
|
||||
phone=form.phone.data,
|
||||
company=form.company.data,
|
||||
position=form.position.data,
|
||||
notes=form.notes.data,
|
||||
is_active=True, # Set default value
|
||||
is_admin=form.is_admin.data,
|
||||
profile_picture=profile_picture
|
||||
)
|
||||
user.set_password('changeme') # Set default password
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
# Generate a random password
|
||||
alphabet = string.ascii_letters + string.digits + string.punctuation
|
||||
random_password = ''.join(secrets.choice(alphabet) for _ in range(32))
|
||||
|
||||
# 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()
|
||||
}
|
||||
)
|
||||
# Create new user
|
||||
user = User(
|
||||
username=form.first_name.data,
|
||||
last_name=form.last_name.data,
|
||||
email=form.email.data,
|
||||
phone=form.phone.data,
|
||||
company=form.company.data,
|
||||
position=form.position.data,
|
||||
notes=form.notes.data,
|
||||
is_admin=(form.role.data == 'admin'),
|
||||
is_manager=(form.role.data == 'manager'),
|
||||
profile_picture=profile_picture
|
||||
)
|
||||
user.set_password(random_password)
|
||||
db.session.add(user)
|
||||
|
||||
# Log user creation event
|
||||
log_event(
|
||||
event_type='user_create',
|
||||
details={
|
||||
'created_by': current_user.id,
|
||||
'created_by_name': f"{current_user.username} {current_user.last_name}",
|
||||
'user_id': user.id,
|
||||
'user_name': f"{user.username} {user.last_name}",
|
||||
'email': user.email,
|
||||
'is_admin': user.is_admin,
|
||||
'method': 'admin_creation'
|
||||
}
|
||||
)
|
||||
db.session.commit()
|
||||
# Log user creation event
|
||||
log_event(
|
||||
event_type='user_create',
|
||||
details={
|
||||
'created_by': current_user.id,
|
||||
'created_by_name': f"{current_user.username} {current_user.last_name}",
|
||||
'user_id': user.id,
|
||||
'user_name': f"{user.username} {user.last_name}",
|
||||
'email': user.email,
|
||||
'role': form.role.data,
|
||||
'method': 'admin_creation'
|
||||
}
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
flash('User created successfully! They will need to change their password on first login.', 'success')
|
||||
return redirect(url_for('contacts.contacts_list'))
|
||||
flash('User created successfully! They will receive an email with a link to set up their password.', 'success')
|
||||
return redirect(url_for('contacts.contacts_list'))
|
||||
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
|
||||
|
||||
@contacts_bp.route('/profile/edit', methods=['GET', 'POST'])
|
||||
@@ -261,7 +253,13 @@ def edit_contact(id):
|
||||
form.company.data = user.company
|
||||
form.position.data = user.position
|
||||
form.notes.data = user.notes
|
||||
form.is_admin.data = user.is_admin
|
||||
# Set role based on current permissions
|
||||
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():
|
||||
# Handle profile picture removal
|
||||
if 'remove_picture' in request.form:
|
||||
@@ -289,9 +287,10 @@ def edit_contact(id):
|
||||
user.profile_picture = filename
|
||||
|
||||
# Prevent removing admin from the last admin
|
||||
if not form.is_admin.data and user.is_admin and total_admins <= 1:
|
||||
if form.role.data != 'admin' and user.is_admin and total_admins <= 1:
|
||||
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)
|
||||
|
||||
# Check if the new email is already used by another user
|
||||
if form.email.data != user.email:
|
||||
existing_user = User.query.filter_by(email=form.email.data).first()
|
||||
@@ -306,7 +305,7 @@ def edit_contact(id):
|
||||
'phone': user.phone,
|
||||
'company': user.company,
|
||||
'position': user.position,
|
||||
'is_admin': user.is_admin
|
||||
'role': 'admin' if user.is_admin else 'manager' if user.is_manager else 'user'
|
||||
}
|
||||
|
||||
user.username = form.first_name.data
|
||||
@@ -316,7 +315,8 @@ def edit_contact(id):
|
||||
user.company = form.company.data
|
||||
user.position = form.position.data
|
||||
user.notes = form.notes.data
|
||||
user.is_admin = form.is_admin.data
|
||||
user.is_admin = (form.role.data == 'admin')
|
||||
user.is_manager = (form.role.data == 'manager')
|
||||
|
||||
# Set password if provided
|
||||
password_changed = False
|
||||
@@ -340,6 +340,7 @@ def edit_contact(id):
|
||||
'phone': user.phone,
|
||||
'company': user.company,
|
||||
'position': user.position,
|
||||
'role': form.role.data,
|
||||
'password_changed': password_changed
|
||||
},
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
@@ -361,7 +362,7 @@ def edit_contact(id):
|
||||
'phone': user.phone,
|
||||
'company': user.company,
|
||||
'position': user.position,
|
||||
'is_admin': user.is_admin
|
||||
'role': form.role.data
|
||||
},
|
||||
'password_changed': password_changed,
|
||||
'method': 'admin_update'
|
||||
@@ -446,4 +447,54 @@ def toggle_active(id):
|
||||
db.session.commit()
|
||||
|
||||
flash(f'User marked as {"active" if user.is_active else "inactive"}!', 'success')
|
||||
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'))
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Conversation, User, Message, MessageAttachment
|
||||
from models import db, Conversation, User, Message, MessageAttachment, DocuPulseSettings
|
||||
from forms import ConversationForm
|
||||
from routes.auth import require_password_change
|
||||
from utils import log_event, create_notification, get_unread_count
|
||||
@@ -55,14 +55,15 @@ def conversations():
|
||||
query = query.filter(Conversation.name.ilike(f'%{search}%'))
|
||||
conversations = query.order_by(Conversation.created_at.desc()).all()
|
||||
unread_count = get_unread_count(current_user.id)
|
||||
return render_template('conversations/conversations.html', conversations=conversations, search=search, unread_notifications=unread_count)
|
||||
usage_stats = DocuPulseSettings.get_usage_stats()
|
||||
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'])
|
||||
@login_required
|
||||
@require_password_change
|
||||
def create_conversation():
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can create conversations.', 'error')
|
||||
if not (current_user.is_admin or current_user.is_manager):
|
||||
flash('Only administrators and managers can create conversations.', 'error')
|
||||
return redirect(url_for('conversations.conversations'))
|
||||
|
||||
form = ConversationForm()
|
||||
@@ -148,8 +149,8 @@ def conversation(conversation_id):
|
||||
# Query messages directly using the Message model
|
||||
messages = Message.query.filter_by(conversation_id=conversation_id).order_by(Message.created_at.asc()).all()
|
||||
|
||||
# Get all users for member selection (only needed for admin)
|
||||
all_users = User.query.all() if current_user.is_admin else None
|
||||
# Get all users for member selection (needed for admin and manager)
|
||||
all_users = User.query.all() if (current_user.is_admin or current_user.is_manager) else None
|
||||
|
||||
unread_count = get_unread_count(current_user.id)
|
||||
return render_template('conversations/conversation.html',
|
||||
@@ -167,8 +168,8 @@ def conversation_members(conversation_id):
|
||||
flash('You do not have access to this conversation.', 'error')
|
||||
return redirect(url_for('conversations.conversations'))
|
||||
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can manage conversation members.', 'error')
|
||||
if not (current_user.is_admin or current_user.is_manager):
|
||||
flash('Only administrators and managers can manage conversation members.', 'error')
|
||||
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()
|
||||
|
||||
1857
routes/launch_api.py
Normal file
1857
routes/launch_api.py
Normal file
File diff suppressed because it is too large
Load Diff
1519
routes/main.py
1519
routes/main.py
File diff suppressed because it is too large
Load Diff
103
routes/public.py
Normal file
103
routes/public.py
Normal file
@@ -0,0 +1,103 @@
|
||||
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:
|
||||
# Show all articles when no specific category is requested
|
||||
articles = []
|
||||
for category_articles in all_articles.values():
|
||||
articles.extend(category_articles)
|
||||
# Sort by order_index and then by created_at
|
||||
articles.sort(key=lambda x: (x.order_index, x.created_at))
|
||||
category_name = None
|
||||
|
||||
return render_template('public/help_articles.html',
|
||||
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')
|
||||
@@ -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')
|
||||
|
||||
# Root directory for storing room files
|
||||
DATA_ROOT = '/data/rooms' # This should be a Docker volume
|
||||
DATA_ROOT = '/app/uploads/rooms' # Updated to match Docker volume mount point
|
||||
|
||||
# Set of allowed file extensions for upload
|
||||
ALLOWED_EXTENSIONS = {
|
||||
@@ -217,7 +217,6 @@ def upload_room_file(room_id):
|
||||
|
||||
# If we are overwriting, delete the trashed file record
|
||||
db.session.delete(trashed_file)
|
||||
db.session.commit()
|
||||
existing_file = None
|
||||
|
||||
file.save(file_path)
|
||||
@@ -347,6 +346,19 @@ def delete_file(room_id, filename):
|
||||
if not rf:
|
||||
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
|
||||
rf.deleted = True
|
||||
rf.deleted_by = current_user.id
|
||||
@@ -1052,6 +1064,9 @@ def delete_permanent(room_id):
|
||||
|
||||
for item in contained_items:
|
||||
db.session.delete(item)
|
||||
|
||||
# Delete the database record
|
||||
db.session.delete(rf)
|
||||
|
||||
log_event(
|
||||
event_type='file_delete_permanent',
|
||||
@@ -1066,10 +1081,15 @@ def delete_permanent(room_id):
|
||||
user_id=current_user.id
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error deleting {rf.type} from storage: {e}")
|
||||
|
||||
# Delete the database record
|
||||
db.session.delete(rf)
|
||||
print(f"Error deleting file {rf.name}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Commit all changes
|
||||
try:
|
||||
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
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Room, User, RoomMemberPermission, RoomFile, Notif
|
||||
from models import db, Room, User, RoomMemberPermission, RoomFile, Notif, DocuPulseSettings
|
||||
from forms import RoomForm
|
||||
from routes.room_files import user_has_permission
|
||||
from routes.auth import require_password_change
|
||||
@@ -36,7 +36,11 @@ def rooms():
|
||||
if search:
|
||||
query = query.filter(Room.name.ilike(f'%{search}%'))
|
||||
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'])
|
||||
@login_required
|
||||
@@ -85,7 +89,7 @@ def create_room():
|
||||
@require_password_change
|
||||
def room(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Admins always have access
|
||||
# Admins always have access, managers need to be members
|
||||
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
|
||||
if not is_member:
|
||||
@@ -116,14 +120,15 @@ def room(room_id):
|
||||
@require_password_change
|
||||
def room_members(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Admins always have access
|
||||
# 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
|
||||
if not is_member:
|
||||
flash('You do not have access to this room.', 'error')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can manage room members.', 'error')
|
||||
# Only admins and managers who are members can manage room members
|
||||
if not (current_user.is_admin or (current_user.is_manager and is_member)):
|
||||
flash('Only administrators and managers can manage room members.', 'error')
|
||||
return redirect(url_for('rooms.room', room_id=room_id))
|
||||
member_permissions = {p.user_id: p for p in room.member_permissions}
|
||||
available_users = User.query.filter(~User.id.in_(member_permissions.keys())).all()
|
||||
@@ -139,8 +144,9 @@ def add_member(room_id):
|
||||
if not is_member:
|
||||
flash('You do not have access to this room.', 'error')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can manage room members.', 'error')
|
||||
# Only admins and managers who are members can manage room members
|
||||
if not (current_user.is_admin or (current_user.is_manager and is_member)):
|
||||
flash('Only administrators and managers can manage room members.', 'error')
|
||||
return redirect(url_for('rooms.room', room_id=room_id))
|
||||
user_id = request.form.get('user_id')
|
||||
if not user_id:
|
||||
@@ -211,59 +217,30 @@ def remove_member(room_id, user_id):
|
||||
if not is_member:
|
||||
flash('You do not have access to this room.', 'error')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can manage room members.', 'error')
|
||||
# Only admins and managers who are members can manage room members
|
||||
if not (current_user.is_admin or (current_user.is_manager and is_member)):
|
||||
flash('Only administrators and managers can manage room members.', 'error')
|
||||
return redirect(url_for('rooms.room', room_id=room_id))
|
||||
if user_id == room.created_by:
|
||||
flash('Cannot remove the room creator.', 'error')
|
||||
else:
|
||||
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
|
||||
if not perm:
|
||||
flash('User is not a member of this room.', 'error')
|
||||
if perm:
|
||||
db.session.delete(perm)
|
||||
db.session.commit()
|
||||
flash('Member has been removed from the room.', 'success')
|
||||
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.commit()
|
||||
flash('User has been removed from the room.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('An error occurred while removing the member.', 'error')
|
||||
print(f"Error removing member: {str(e)}")
|
||||
|
||||
flash('Member not found.', 'error')
|
||||
return redirect(url_for('rooms.room_members', room_id=room_id))
|
||||
|
||||
@rooms_bp.route('/<int:room_id>/members/<int:user_id>/permissions', methods=['POST'])
|
||||
@login_required
|
||||
def update_member_permissions(room_id, user_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can update permissions.', 'error')
|
||||
# Check if user is a member
|
||||
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 update permissions.', 'error')
|
||||
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()
|
||||
if not perm:
|
||||
@@ -312,11 +289,13 @@ def update_member_permissions(room_id, user_id):
|
||||
@rooms_bp.route('/<int:room_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_room(room_id):
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can edit rooms.', 'error')
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Check if user is a member
|
||||
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'))
|
||||
|
||||
room = Room.query.get_or_404(room_id)
|
||||
form = RoomForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
@@ -354,18 +333,20 @@ def edit_room(room_id):
|
||||
@rooms_bp.route('/<int:room_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_room(room_id):
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can delete rooms.', 'error')
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Check if user is a member
|
||||
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'))
|
||||
|
||||
room = Room.query.get_or_404(room_id)
|
||||
room_name = room.name
|
||||
|
||||
try:
|
||||
print(f"Attempting to delete room {room_id} ({room_name})")
|
||||
|
||||
# Delete physical files
|
||||
room_dir = os.path.join('/data/rooms', str(room_id))
|
||||
room_dir = os.path.join('/app/uploads/rooms', str(room_id))
|
||||
if os.path.exists(room_dir):
|
||||
shutil.rmtree(room_dir)
|
||||
print(f"Deleted room directory: {room_dir}")
|
||||
|
||||
59
set_version.py
Normal file
59
set_version.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/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()
|
||||
6
start.sh
6
start.sh
@@ -6,12 +6,6 @@ while ! nc -z db 5432; do
|
||||
done
|
||||
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..."
|
||||
flask db upgrade
|
||||
|
||||
|
||||
@@ -88,10 +88,90 @@ body {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Enhanced Card Styles */
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
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 {
|
||||
@@ -108,6 +188,9 @@ body {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.document-card:hover {
|
||||
transform: translateY(-5px);
|
||||
|
||||
/* Remove hover effect from list group items */
|
||||
.list-group-item-action:hover {
|
||||
background-color: transparent !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
@@ -40,4 +40,15 @@
|
||||
--shadow-color-light: rgba(0, 0, 0, 0.25);
|
||||
--border-color: #dee2e6;
|
||||
--border-light: #e9ecef;
|
||||
}
|
||||
|
||||
/* Text Selection Styles */
|
||||
::selection {
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--white);
|
||||
}
|
||||
@@ -1,22 +1,48 @@
|
||||
/* Enhanced Homepage Styles */
|
||||
.navbar {
|
||||
background-color: var(--primary-color) !important;
|
||||
background: var(--white) !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar.scrolled {
|
||||
background: var(--white) !important;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
padding: 100px 0;
|
||||
padding: 120px 0 100px 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
background-color: var(--bg-color);
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
transition: transform 0.3s;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 15px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 5px 15px var(--shadow-color);
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 35px var(--shadow-color-light);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
@@ -25,30 +51,222 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.testimonial-card {
|
||||
background: var(--white);
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 5px 15px var(--shadow-color);
|
||||
margin: 20px 0;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.testimonial-card:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.pricing-card {
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 5px 15px var(--shadow-color);
|
||||
}
|
||||
|
||||
.pricing-card:hover {
|
||||
transform: translateY(-10px);
|
||||
box-shadow: 0 20px 40px var(--shadow-color-light);
|
||||
}
|
||||
|
||||
.pricing-card.border-primary {
|
||||
border: 2px solid var(--primary-color) !important;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
padding: 12px 30px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-light);
|
||||
border-color: var(--primary-light);
|
||||
background: linear-gradient(135deg, var(--primary-light) 0%, var(--secondary-light) 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
border: 2px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
border-radius: 25px;
|
||||
padding: 12px 30px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--primary-color);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: var(--primary-color) !important;
|
||||
.btn-light {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
padding: 12px 30px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-light:hover {
|
||||
background: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-outline-light {
|
||||
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||
color: white;
|
||||
border-radius: 25px;
|
||||
padding: 12px 30px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-outline-light:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.admin-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.admin-link a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
box-shadow: 0 2px 10px var(--shadow-color);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--secondary-color) !important;
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.hero-section {
|
||||
padding: 100px 0 80px 0;
|
||||
}
|
||||
|
||||
.display-3 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.display-5 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.pricing-card.border-primary {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Feature icon backgrounds */
|
||||
.feature-icon-bg {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Stats section text colors */
|
||||
.stats-section .h2 {
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.stats-section .text-muted {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Pricing card highlights */
|
||||
.pricing-card.border-primary {
|
||||
border: 2px solid var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.pricing-card .card-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
border-radius: 15px 15px 0 0 !important;
|
||||
}
|
||||
|
||||
/* Fade in animations */
|
||||
.fade-in {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all 0.6s ease;
|
||||
}
|
||||
|
||||
.fade-in.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
252
static/css/instances.css
Normal file
252
static/css/instances.css
Normal file
@@ -0,0 +1,252 @@
|
||||
/* Instances Page Styles */
|
||||
|
||||
/* Table Styles */
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Version column styling */
|
||||
.version-badge {
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.branch-badge {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Make table responsive */
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Tooltip styling for version info */
|
||||
.tooltip-inner {
|
||||
max-width: 300px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Version comparison styling */
|
||||
.version-outdated {
|
||||
background-color: #fff3cd !important;
|
||||
border-color: #ffeaa7 !important;
|
||||
}
|
||||
|
||||
.version-current {
|
||||
background-color: #d1ecf1 !important;
|
||||
border-color: #bee5eb !important;
|
||||
}
|
||||
|
||||
.badge.bg-orange {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.badge.bg-orange:hover {
|
||||
background-color: #e55a00 !important;
|
||||
}
|
||||
|
||||
/* Pricing tier selection styles */
|
||||
.pricing-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.pricing-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pricing-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(22, 118, 123, 0.05);
|
||||
box-shadow: 0 4px 12px rgba(22, 118, 123, 0.2);
|
||||
}
|
||||
|
||||
.pricing-card.selected::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pricing-card.border-primary {
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.quota-info {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.features {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Step Navigation Styles */
|
||||
.step-item {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-item:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: -50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: #e9ecef;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.step-circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #e9ecef;
|
||||
color: #6c757d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 8px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.step-item.active .step-circle {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-item.active .step-label {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.step-item.completed .step-circle {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-item.completed:not(:last-child)::after {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.step-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Connection Check Styles */
|
||||
.connection-check {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.connection-status.success {
|
||||
color: #198754;
|
||||
}
|
||||
|
||||
.connection-status.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.connection-details {
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Modal Footer Styles */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end !important;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-footer button {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Infrastructure Tools Styles */
|
||||
.infrastructure-tools .btn {
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 12px;
|
||||
min-height: 100px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.infrastructure-tools .btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.infrastructure-tools .btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.infrastructure-tools .btn:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.infrastructure-tools .btn-outline-primary:hover {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.infrastructure-tools .btn-outline-success:hover {
|
||||
background-color: #198754;
|
||||
border-color: #198754;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.infrastructure-tools .btn-outline-info:hover {
|
||||
background-color: #0dcaf0;
|
||||
border-color: #0dcaf0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.infrastructure-tools .btn-outline-warning:hover {
|
||||
background-color: #ffc107;
|
||||
border-color: #ffc107;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.infrastructure-tools .btn i {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.infrastructure-tools .btn:hover i {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user