Compare commits
13 Commits
0.10
...
5c2b300c28
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c2b300c28 | |||
| 6f8216cd37 | |||
| 07e224ccbf | |||
| e6ba4f3d8a | |||
| 33844ddd3e | |||
| 583710763e | |||
| 6708d4afaf | |||
| 24fbc74c87 | |||
| 0f4b21818b | |||
| 6b0012c423 | |||
| 44fd8433a1 | |||
| b493446048 | |||
| b72acbf912 |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Exclude everything
|
||||||
|
*
|
||||||
|
|
||||||
|
# Include specific files and directories
|
||||||
|
!start.sh
|
||||||
|
!requirements.txt
|
||||||
|
!app.py
|
||||||
|
!celery_worker.py
|
||||||
|
!models.py
|
||||||
|
!extensions.py
|
||||||
|
!utils/
|
||||||
|
!routes/
|
||||||
|
!templates/
|
||||||
|
!static/
|
||||||
|
!migrations/
|
||||||
|
!uploads/
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,3 @@ logs/
|
|||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
# Python cache
|
|
||||||
__pycache__/
|
|
||||||
30
Dockerfile
30
Dockerfile
@@ -4,10 +4,9 @@ FROM python:3.11-slim
|
|||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
postgresql-client \
|
|
||||||
curl \
|
curl \
|
||||||
|
wget \
|
||||||
netcat-traditional \
|
netcat-traditional \
|
||||||
dos2unix \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create a non-root user
|
# Create a non-root user
|
||||||
@@ -16,23 +15,24 @@ RUN useradd -m -u 1000 celery
|
|||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy requirements first to leverage Docker cache
|
# Copy the entire application
|
||||||
COPY requirements.txt .
|
COPY . /app/
|
||||||
|
|
||||||
|
# Set up start.sh
|
||||||
|
RUN chmod +x /app/start.sh && \
|
||||||
|
chown celery:celery /app/start.sh
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Create necessary directories and set permissions
|
||||||
COPY . .
|
RUN mkdir -p /data/rooms && \
|
||||||
|
chown -R celery:celery /data && \
|
||||||
# Convert line endings and set permissions
|
chmod -R 755 /data && \
|
||||||
RUN dos2unix /app/entrypoint.sh && \
|
chown -R celery:celery /app
|
||||||
chmod +x /app/entrypoint.sh && \
|
|
||||||
mkdir -p /app/uploads/rooms /app/uploads/profile_pics /app/static/uploads && \
|
|
||||||
chown -R celery:celery /app && \
|
|
||||||
chmod -R 755 /app/uploads
|
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER celery
|
USER celery
|
||||||
|
|
||||||
# Set entrypoint
|
# Set entrypoint
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
ENTRYPOINT ["/app/start.sh"]
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
{
|
|
||||||
"openapi": "3.1.0",
|
|
||||||
"info": {
|
|
||||||
"title": "Nginx Proxy Manager API",
|
|
||||||
"version": "2.x.x"
|
|
||||||
},
|
|
||||||
"servers": [
|
|
||||||
{
|
|
||||||
"url": "http://127.0.0.1:81/api"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"components": {
|
|
||||||
"securitySchemes": {
|
|
||||||
"bearerAuth": {
|
|
||||||
"type": "http",
|
|
||||||
"scheme": "bearer",
|
|
||||||
"bearerFormat": "JWT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"paths": {
|
|
||||||
"/": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/audit-log": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/audit-log/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/access-lists": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/access-lists/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/access-lists/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/access-lists/{listID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/access-lists/listID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/nginx/access-lists/listID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/access-lists/listID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/certificates/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/certificates/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/validate": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/certificates/validate/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/test-http": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/certificates/test-http/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/{certID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/certificates/certID/get.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/certificates/certID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/{certID}/download": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/certificates/certID/download/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/{certID}/renew": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/certificates/certID/renew/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/{certID}/upload": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/certificates/certID/upload/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/proxy-hosts": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/proxy-hosts/{hostID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/hostID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/hostID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/hostID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/proxy-hosts/{hostID}/enable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/hostID/enable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/proxy-hosts/{hostID}/disable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/hostID/disable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/redirection-hosts": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/redirection-hosts/{hostID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/hostID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/hostID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/hostID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/redirection-hosts/{hostID}/enable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/hostID/enable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/redirection-hosts/{hostID}/disable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/hostID/disable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/dead-hosts": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/dead-hosts/{hostID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/hostID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/hostID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/hostID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/dead-hosts/{hostID}/enable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/hostID/enable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/dead-hosts/{hostID}/disable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/hostID/disable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/streams": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/streams/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/streams/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/streams/{streamID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/streams/streamID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/nginx/streams/streamID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/streams/streamID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/streams/{streamID}/enable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/streams/streamID/enable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/streams/{streamID}/disable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/streams/streamID/disable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/reports/hosts": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/reports/hosts/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/schema": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/schema/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/settings": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/settings/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/settings/{settingID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/settings/settingID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/settings/settingID/put.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/tokens": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/tokens/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/tokens/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/users": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/users/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/users/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/users/{userID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/users/userID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/users/userID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/users/userID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/users/{userID}/auth": {
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/users/userID/auth/put.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/users/{userID}/permissions": {
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/users/userID/permissions/put.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/users/{userID}/login": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/users/userID/login/post.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
53
README.md
53
README.md
@@ -10,9 +10,8 @@ DocuPulse is a powerful document management system designed to streamline docume
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Python 3.11 or higher
|
- Node.js (version 18 or higher)
|
||||||
- PostgreSQL 13 or higher
|
- npm or yarn
|
||||||
- Docker and Docker Compose (for containerized deployment)
|
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -24,50 +23,18 @@ cd docupulse
|
|||||||
|
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
npm install
|
||||||
|
# or
|
||||||
|
yarn install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Set up environment variables:
|
3. Start the development server:
|
||||||
```bash
|
```bash
|
||||||
# Copy example environment file
|
npm run dev
|
||||||
cp .env.example .env
|
# or
|
||||||
|
yarn dev
|
||||||
# Set version information for local development
|
|
||||||
python set_version.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Initialize the database:
|
|
||||||
```bash
|
|
||||||
flask db upgrade
|
|
||||||
flask create-admin
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Start the development server:
|
|
||||||
```bash
|
|
||||||
python app.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Version Tracking
|
|
||||||
|
|
||||||
DocuPulse uses a database-only approach for version tracking:
|
|
||||||
|
|
||||||
- **Environment Variables**: Version information is passed via environment variables (`APP_VERSION`, `GIT_COMMIT`, `GIT_BRANCH`, `DEPLOYED_AT`)
|
|
||||||
- **Database Storage**: Instance version information is stored in the `instances` table
|
|
||||||
- **API Endpoint**: Version information is available via `/api/version`
|
|
||||||
|
|
||||||
### Setting Version Information
|
|
||||||
|
|
||||||
For local development:
|
|
||||||
```bash
|
|
||||||
python set_version.py
|
|
||||||
```
|
|
||||||
|
|
||||||
For production deployments, set the following environment variables:
|
|
||||||
- `APP_VERSION`: Application version/tag
|
|
||||||
- `GIT_COMMIT`: Git commit hash
|
|
||||||
- `GIT_BRANCH`: Git branch name
|
|
||||||
- `DEPLOYED_AT`: Deployment timestamp
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Document upload and management
|
- Document upload and management
|
||||||
@@ -75,8 +42,6 @@ For production deployments, set the following environment variables:
|
|||||||
- Secure document storage
|
- Secure document storage
|
||||||
- User authentication and authorization
|
- User authentication and authorization
|
||||||
- Document version control
|
- Document version control
|
||||||
- Multi-tenant instance management
|
|
||||||
- RESTful API
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
119
app.py
119
app.py
@@ -8,22 +8,15 @@ from flask_wtf.csrf import generate_csrf
|
|||||||
from routes.room_files import room_files_bp
|
from routes.room_files import room_files_bp
|
||||||
from routes.room_members import room_members_bp
|
from routes.room_members import room_members_bp
|
||||||
from routes.trash import trash_bp
|
from routes.trash import trash_bp
|
||||||
from routes.admin_api import admin_api
|
|
||||||
from routes.launch_api import launch_api
|
|
||||||
from tasks import cleanup_trash
|
from tasks import cleanup_trash
|
||||||
import click
|
import click
|
||||||
from utils import timeago
|
from utils import timeago
|
||||||
from extensions import db, login_manager, csrf
|
from extensions import db, login_manager, csrf
|
||||||
from utils.email_templates import create_default_templates
|
from utils.email_templates import create_default_templates
|
||||||
from datetime import datetime
|
from celery_worker import init_celery, celery
|
||||||
from sqlalchemy import text
|
|
||||||
from utils.asset_utils import get_asset_version
|
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
print("Environment variables after loading .env:")
|
|
||||||
print(f"MASTER: {os.getenv('MASTER')}")
|
|
||||||
print(f"ISMASTER: {os.getenv('ISMASTER')}")
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -34,12 +27,6 @@ def create_app():
|
|||||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secure-secret-key-here')
|
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secure-secret-key-here')
|
||||||
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads')
|
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads')
|
||||||
app.config['CSS_VERSION'] = os.getenv('CSS_VERSION', '1.0.3') # Add CSS version for cache busting
|
app.config['CSS_VERSION'] = os.getenv('CSS_VERSION', '1.0.3') # Add CSS version for cache busting
|
||||||
app.config['SERVER_NAME'] = os.getenv('SERVER_NAME', '127.0.0.1:5000')
|
|
||||||
app.config['PREFERRED_URL_SCHEME'] = os.getenv('PREFERRED_URL_SCHEME', 'http')
|
|
||||||
|
|
||||||
# Configure request timeouts for long-running operations
|
|
||||||
app.config['REQUEST_TIMEOUT'] = int(os.getenv('REQUEST_TIMEOUT', '300')) # 5 minutes default
|
|
||||||
app.config['STACK_DEPLOYMENT_TIMEOUT'] = int(os.getenv('STACK_DEPLOYMENT_TIMEOUT', '300')) # 5 minutes for stack deployment
|
|
||||||
|
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
@@ -48,6 +35,9 @@ def create_app():
|
|||||||
login_manager.login_view = 'auth.login'
|
login_manager.login_view = 'auth.login'
|
||||||
csrf.init_app(app)
|
csrf.init_app(app)
|
||||||
|
|
||||||
|
# Initialize Celery
|
||||||
|
init_celery(app)
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_csrf_token():
|
def inject_csrf_token():
|
||||||
return dict(csrf_token=generate_csrf())
|
return dict(csrf_token=generate_csrf())
|
||||||
@@ -61,20 +51,6 @@ def create_app():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return dict(config=app.config, site_settings=site_settings)
|
return dict(config=app.config, site_settings=site_settings)
|
||||||
|
|
||||||
@app.context_processor
|
|
||||||
def inject_unread_notifications():
|
|
||||||
from flask_login import current_user
|
|
||||||
from utils import get_unread_count
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
unread_count = get_unread_count(current_user.id)
|
|
||||||
return {'unread_notifications': unread_count}
|
|
||||||
return {'unread_notifications': 0}
|
|
||||||
|
|
||||||
@app.template_filter('asset_version')
|
|
||||||
def asset_version_filter(filename):
|
|
||||||
"""Template filter to get version hash for static assets"""
|
|
||||||
return get_asset_version(filename) or ''
|
|
||||||
|
|
||||||
# User loader for Flask-Login
|
# User loader for Flask-Login
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
@@ -84,21 +60,19 @@ def create_app():
|
|||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health_check():
|
def health_check():
|
||||||
try:
|
try:
|
||||||
# Check database connection with a timeout
|
# Check database connection
|
||||||
db.session.execute(text('SELECT 1'))
|
db.session.execute('SELECT 1')
|
||||||
db.session.commit()
|
# Check Redis connection
|
||||||
|
celery.control.inspect().ping()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'healthy',
|
'status': 'healthy',
|
||||||
'database': 'connected',
|
'database': 'connected',
|
||||||
'timestamp': datetime.utcnow().isoformat()
|
'redis': 'connected'
|
||||||
}), 200
|
}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Health check failed: {str(e)}")
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'unhealthy',
|
'status': 'unhealthy',
|
||||||
'error': str(e),
|
'error': str(e)
|
||||||
'timestamp': datetime.utcnow().isoformat()
|
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
# Initialize routes
|
# Initialize routes
|
||||||
@@ -107,8 +81,6 @@ def create_app():
|
|||||||
app.register_blueprint(room_files_bp, url_prefix='/api/rooms')
|
app.register_blueprint(room_files_bp, url_prefix='/api/rooms')
|
||||||
app.register_blueprint(room_members_bp, url_prefix='/api/rooms')
|
app.register_blueprint(room_members_bp, url_prefix='/api/rooms')
|
||||||
app.register_blueprint(trash_bp, url_prefix='/api/trash')
|
app.register_blueprint(trash_bp, url_prefix='/api/trash')
|
||||||
app.register_blueprint(admin_api, url_prefix='/api/admin')
|
|
||||||
app.register_blueprint(launch_api, url_prefix='/api/admin')
|
|
||||||
|
|
||||||
@app.cli.command("cleanup-trash")
|
@app.cli.command("cleanup-trash")
|
||||||
def cleanup_trash_command():
|
def cleanup_trash_command():
|
||||||
@@ -116,13 +88,6 @@ def create_app():
|
|||||||
cleanup_trash()
|
cleanup_trash()
|
||||||
click.echo("Trash cleanup completed.")
|
click.echo("Trash cleanup completed.")
|
||||||
|
|
||||||
@app.cli.command("cleanup-tokens")
|
|
||||||
def cleanup_tokens_command():
|
|
||||||
"""Clean up expired password reset and setup tokens."""
|
|
||||||
from tasks import cleanup_expired_tokens
|
|
||||||
cleanup_expired_tokens()
|
|
||||||
click.echo("Token cleanup completed.")
|
|
||||||
|
|
||||||
@app.cli.command("create-admin")
|
@app.cli.command("create-admin")
|
||||||
def create_admin():
|
def create_admin():
|
||||||
"""Create the default administrator user."""
|
"""Create the default administrator user."""
|
||||||
@@ -134,20 +99,15 @@ def create_app():
|
|||||||
admin = User(
|
admin = User(
|
||||||
username='administrator',
|
username='administrator',
|
||||||
email='administrator@docupulse.com',
|
email='administrator@docupulse.com',
|
||||||
last_name='Administrator',
|
last_name='None',
|
||||||
company='DocuPulse',
|
company='docupulse',
|
||||||
position='System Administrator',
|
|
||||||
is_admin=True,
|
is_admin=True,
|
||||||
is_active=True,
|
is_active=True
|
||||||
preferred_view='grid'
|
|
||||||
)
|
)
|
||||||
admin.set_password('changeme')
|
admin.set_password('changeme')
|
||||||
db.session.add(admin)
|
db.session.add(admin)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
click.echo("Default administrator user created successfully.")
|
click.echo("Default administrator user created successfully.")
|
||||||
click.echo("Admin credentials:")
|
|
||||||
click.echo("Email: administrator@docupulse.com")
|
|
||||||
click.echo("Password: changeme")
|
|
||||||
|
|
||||||
# Register custom filters
|
# Register custom filters
|
||||||
app.jinja_env.filters['timeago'] = timeago
|
app.jinja_env.filters['timeago'] = timeago
|
||||||
@@ -157,29 +117,6 @@ def create_app():
|
|||||||
try:
|
try:
|
||||||
# Ensure database tables exist
|
# Ensure database tables exist
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
# Create admin user first
|
|
||||||
admin = User.query.filter_by(email='administrator@docupulse.com').first()
|
|
||||||
if not admin:
|
|
||||||
admin = User(
|
|
||||||
username='administrator',
|
|
||||||
email='administrator@docupulse.com',
|
|
||||||
last_name='Administrator',
|
|
||||||
company='DocuPulse',
|
|
||||||
position='System Administrator',
|
|
||||||
is_admin=True,
|
|
||||||
is_active=True,
|
|
||||||
preferred_view='grid'
|
|
||||||
)
|
|
||||||
admin.set_password('changeme')
|
|
||||||
db.session.add(admin)
|
|
||||||
db.session.commit()
|
|
||||||
print("Default administrator user created successfully.")
|
|
||||||
print("Admin credentials:")
|
|
||||||
print("Email: administrator@docupulse.com")
|
|
||||||
print("Password: changeme")
|
|
||||||
|
|
||||||
# Then create default templates
|
|
||||||
create_default_templates()
|
create_default_templates()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Could not create default templates: {e}")
|
print(f"Warning: Could not create default templates: {e}")
|
||||||
@@ -188,37 +125,9 @@ def create_app():
|
|||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
@app.errorhandler(404)
|
|
||||||
def page_not_found(e):
|
|
||||||
from flask import render_template
|
|
||||||
return render_template('common/404.html'), 404
|
|
||||||
|
|
||||||
@app.errorhandler(403)
|
|
||||||
def forbidden(e):
|
|
||||||
from flask import render_template
|
|
||||||
return render_template('common/403.html'), 403
|
|
||||||
|
|
||||||
@app.errorhandler(401)
|
|
||||||
def unauthorized(e):
|
|
||||||
from flask import render_template
|
|
||||||
return render_template('common/401.html'), 401
|
|
||||||
|
|
||||||
@app.errorhandler(400)
|
|
||||||
def bad_request(e):
|
|
||||||
from flask import render_template
|
|
||||||
return render_template('common/400.html'), 400
|
|
||||||
|
|
||||||
@app.errorhandler(500)
|
|
||||||
def internal_server_error(e):
|
|
||||||
from flask import render_template
|
|
||||||
import traceback
|
|
||||||
error_details = f"{str(e)}\n\n{traceback.format_exc()}"
|
|
||||||
app.logger.error(f"500 error: {error_details}")
|
|
||||||
return render_template('common/500.html', error=error_details), 500
|
|
||||||
|
|
||||||
@app.route('/uploads/profile_pics/<filename>')
|
@app.route('/uploads/profile_pics/<filename>')
|
||||||
def profile_pic(filename):
|
def profile_pic(filename):
|
||||||
return send_from_directory('/app/uploads/profile_pics', filename)
|
return send_from_directory(os.path.join(os.getcwd(), 'uploads', 'profile_pics'), filename)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
51
celery_worker.py
Normal file
51
celery_worker.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from celery import Celery
|
||||||
|
from flask import current_app
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Get Redis URL from environment variable or use default
|
||||||
|
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||||
|
|
||||||
|
# Configure Celery
|
||||||
|
celery = Celery(
|
||||||
|
'docupulse',
|
||||||
|
backend=REDIS_URL,
|
||||||
|
broker=REDIS_URL,
|
||||||
|
# Add some default configuration
|
||||||
|
task_serializer='json',
|
||||||
|
accept_content=['json'],
|
||||||
|
result_serializer='json',
|
||||||
|
timezone='UTC',
|
||||||
|
enable_utc=True,
|
||||||
|
# Add retry configuration
|
||||||
|
task_acks_late=True,
|
||||||
|
task_reject_on_worker_lost=True,
|
||||||
|
task_default_retry_delay=300, # 5 minutes
|
||||||
|
task_max_retries=3
|
||||||
|
)
|
||||||
|
|
||||||
|
def init_celery(app):
|
||||||
|
"""Initialize Celery with Flask app context"""
|
||||||
|
celery.conf.update(app.config)
|
||||||
|
|
||||||
|
class ContextTask(celery.Task):
|
||||||
|
"""Celery task that runs within Flask app context"""
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
with app.app_context():
|
||||||
|
return self.run(*args, **kwargs)
|
||||||
|
|
||||||
|
def on_failure(self, exc, task_id, args, kwargs, einfo):
|
||||||
|
"""Handle task failure"""
|
||||||
|
logger.error(f'Task {task_id} failed: {exc}')
|
||||||
|
super().on_failure(exc, task_id, args, kwargs, einfo)
|
||||||
|
|
||||||
|
def on_retry(self, exc, task_id, args, kwargs, einfo):
|
||||||
|
"""Handle task retry"""
|
||||||
|
logger.warning(f'Task {task_id} is being retried: {exc}')
|
||||||
|
super().on_retry(exc, task_id, args, kwargs, einfo)
|
||||||
|
|
||||||
|
celery.Task = ContextTask
|
||||||
|
return celery
|
||||||
@@ -1,68 +1,91 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
networks:
|
|
||||||
docupulse_network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
build: .
|
||||||
# context: .
|
command: gunicorn --bind 0.0.0.0:5000 app:app
|
||||||
# dockerfile: Dockerfile
|
|
||||||
context: https://git.kobeamerijckx.com/Kobe/docupulse.git
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-10335}:5000"
|
- "10335:5000"
|
||||||
environment:
|
environment:
|
||||||
- FLASK_APP=app.py
|
- FLASK_APP=app.py
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
- DATABASE_URL=postgresql://docupulse_${PORT:-10335}:docupulse_${PORT:-10335}@db:5432/docupulse_${PORT:-10335}
|
- DATABASE_URL=postgresql://postgres:postgres@db:5432/docupulse
|
||||||
- POSTGRES_USER=docupulse_${PORT:-10335}
|
- POSTGRES_USER=postgres
|
||||||
- POSTGRES_PASSWORD=docupulse_${PORT:-10335}
|
- POSTGRES_PASSWORD=postgres
|
||||||
- POSTGRES_DB=docupulse_${PORT:-10335}
|
- POSTGRES_DB=docupulse
|
||||||
- MASTER=${ISMASTER:-false}
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- APP_VERSION=${APP_VERSION:-unknown}
|
|
||||||
- GIT_COMMIT=${GIT_COMMIT:-unknown}
|
|
||||||
- GIT_BRANCH=${GIT_BRANCH:-unknown}
|
|
||||||
- DEPLOYED_AT=${DEPLOYED_AT:-unknown}
|
|
||||||
volumes:
|
volumes:
|
||||||
- docupulse_uploads:/app/uploads
|
- uploads:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/health"]
|
||||||
interval: 60s
|
interval: 30s
|
||||||
timeout: 30s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 120s
|
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: '1'
|
cpus: '1'
|
||||||
memory: 1G
|
memory: 1G
|
||||||
networks:
|
|
||||||
- docupulse_network
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:13
|
image: postgres:13
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=docupulse_${PORT:-10335}
|
- POSTGRES_USER=postgres
|
||||||
- POSTGRES_PASSWORD=docupulse_${PORT:-10335}
|
- POSTGRES_PASSWORD=postgres
|
||||||
- POSTGRES_DB=docupulse_${PORT:-10335}
|
- POSTGRES_DB=docupulse
|
||||||
volumes:
|
volumes:
|
||||||
- docupulse_postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U docupulse_${PORT:-10335}"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
networks:
|
|
||||||
- docupulse_network
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
ports:
|
||||||
|
- "26379:6379"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
celery_worker:
|
||||||
|
build:
|
||||||
|
context: https://git.kobeamerijckx.com/Kobe/docupulse.git
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
command: celery -A celery_worker.celery worker --loglevel=info
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
environment:
|
||||||
|
- FLASK_APP=app.py
|
||||||
|
- FLASK_ENV=development
|
||||||
|
- DATABASE_URL=postgresql://postgres:postgres@db:5432/docupulse
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "celery", "-A", "celery_worker.celery", "inspect", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
docupulse_postgres_data:
|
postgres_data:
|
||||||
name: docupulse_${PORT:-10335}_postgres_data
|
uploads:
|
||||||
docupulse_uploads:
|
|
||||||
name: docupulse_${PORT:-10335}_uploads
|
|
||||||
180
entrypoint.sh
180
entrypoint.sh
@@ -6,29 +6,21 @@ echo "POSTGRES_USER: $POSTGRES_USER"
|
|||||||
echo "POSTGRES_PASSWORD: $POSTGRES_PASSWORD"
|
echo "POSTGRES_PASSWORD: $POSTGRES_PASSWORD"
|
||||||
echo "POSTGRES_DB: $POSTGRES_DB"
|
echo "POSTGRES_DB: $POSTGRES_DB"
|
||||||
echo "DATABASE_URL: $DATABASE_URL"
|
echo "DATABASE_URL: $DATABASE_URL"
|
||||||
|
echo "REDIS_URL: $REDIS_URL"
|
||||||
|
|
||||||
# Function to wait for database
|
# Wait for the database to be ready
|
||||||
wait_for_db() {
|
echo "Waiting for database to be ready..."
|
||||||
echo "Waiting for database..."
|
while ! nc -z db 5432; do
|
||||||
while ! nc -z db 5432; do
|
sleep 0.1
|
||||||
sleep 1
|
done
|
||||||
done
|
echo "Database is ready!"
|
||||||
echo "Database is ready!"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to create database if it doesn't exist
|
# Wait for Redis to be ready
|
||||||
create_database() {
|
echo "Waiting for Redis to be ready..."
|
||||||
echo "Creating database if it doesn't exist..."
|
while ! nc -z redis 6379; do
|
||||||
PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -tc "SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB'" | grep -q 1 || \
|
sleep 0.1
|
||||||
PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -c "CREATE DATABASE $POSTGRES_DB"
|
done
|
||||||
echo "Database check/creation complete!"
|
echo "Redis is ready!"
|
||||||
}
|
|
||||||
|
|
||||||
# Wait for database to be ready
|
|
||||||
wait_for_db
|
|
||||||
|
|
||||||
# Create database if it doesn't exist
|
|
||||||
create_database
|
|
||||||
|
|
||||||
# Wait for PostgreSQL to be ready to accept connections
|
# Wait for PostgreSQL to be ready to accept connections
|
||||||
echo "Waiting for PostgreSQL to accept connections..."
|
echo "Waiting for PostgreSQL to accept connections..."
|
||||||
@@ -38,112 +30,60 @@ until PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB
|
|||||||
done
|
done
|
||||||
echo "PostgreSQL is up - executing command"
|
echo "PostgreSQL is up - executing command"
|
||||||
|
|
||||||
# Run all initialization in a single Python script to avoid multiple Flask instances
|
# Clean up existing migrations and initialize fresh
|
||||||
echo "Running initialization..."
|
echo "Cleaning up and initializing fresh migrations..."
|
||||||
|
rm -rf migrations/versions/*
|
||||||
|
flask db init
|
||||||
|
flask db migrate -m "Initial migration"
|
||||||
|
flask db upgrade
|
||||||
|
|
||||||
|
# Create events table
|
||||||
|
echo "Creating events table..."
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import sys
|
from migrations.add_events_table import upgrade
|
||||||
from app import create_app
|
from app import create_app
|
||||||
from models import SiteSettings, db, User
|
|
||||||
from utils.email_templates import create_default_templates
|
|
||||||
|
|
||||||
def log_error(message, error=None):
|
|
||||||
print(f'ERROR: {message}', file=sys.stderr)
|
|
||||||
if error:
|
|
||||||
print(f'Error details: {str(error)}', file=sys.stderr)
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
try:
|
try:
|
||||||
# Run migrations
|
|
||||||
print('Running database migrations...')
|
|
||||||
from flask_migrate import upgrade
|
|
||||||
upgrade()
|
upgrade()
|
||||||
print('Database migrations completed successfully')
|
print('Events table created successfully')
|
||||||
|
|
||||||
# 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:
|
except Exception as e:
|
||||||
log_error('Fatal error during initialization', e)
|
print(f'Error creating events table: {e}')
|
||||||
sys.exit(1)
|
"
|
||||||
|
|
||||||
|
# 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()
|
||||||
"
|
"
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
|
|||||||
13
forms.py
13
forms.py
@@ -1,5 +1,5 @@
|
|||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField, SelectMultipleField, SelectField
|
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField, SelectMultipleField
|
||||||
from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError
|
from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError
|
||||||
from models import User
|
from models import User
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
@@ -13,11 +13,7 @@ class UserForm(FlaskForm):
|
|||||||
company = StringField('Company (Optional)', validators=[Optional(), Length(max=100)])
|
company = StringField('Company (Optional)', validators=[Optional(), Length(max=100)])
|
||||||
position = StringField('Position (Optional)', validators=[Optional(), Length(max=100)])
|
position = StringField('Position (Optional)', validators=[Optional(), Length(max=100)])
|
||||||
notes = TextAreaField('Notes (Optional)', validators=[Optional()])
|
notes = TextAreaField('Notes (Optional)', validators=[Optional()])
|
||||||
role = SelectField('Role', choices=[
|
is_admin = BooleanField('Admin Role', default=False)
|
||||||
('user', 'Standard User'),
|
|
||||||
('manager', 'Manager'),
|
|
||||||
('admin', 'Administrator')
|
|
||||||
], validators=[DataRequired()])
|
|
||||||
new_password = PasswordField('New Password (Optional)')
|
new_password = PasswordField('New Password (Optional)')
|
||||||
confirm_password = PasswordField('Confirm Password (Optional)')
|
confirm_password = PasswordField('Confirm Password (Optional)')
|
||||||
profile_picture = FileField('Profile Picture (Optional)', validators=[FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!')])
|
profile_picture = FileField('Profile Picture (Optional)', validators=[FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!')])
|
||||||
@@ -34,11 +30,6 @@ class UserForm(FlaskForm):
|
|||||||
if total_admins <= 1:
|
if total_admins <= 1:
|
||||||
raise ValidationError('There must be at least one admin user in the system.')
|
raise ValidationError('There must be at least one admin user in the system.')
|
||||||
|
|
||||||
def validate_is_manager(self, field):
|
|
||||||
# Prevent setting both admin and manager roles
|
|
||||||
if field.data and self.is_admin.data:
|
|
||||||
raise ValidationError('A user cannot be both an admin and a manager.')
|
|
||||||
|
|
||||||
def validate(self, extra_validators=None):
|
def validate(self, extra_validators=None):
|
||||||
rv = super().validate(extra_validators=extra_validators)
|
rv = super().validate(extra_validators=extra_validators)
|
||||||
if not rv:
|
if not rv:
|
||||||
|
|||||||
61
migrations/add_events_table.py
Normal file
61
migrations/add_events_table.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add the parent directory to Python path so we can import from root
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from extensions import db
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create events table
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
conn.execute(text('''
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES "user" (id),
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
details JSONB,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index on event_type for faster filtering
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_event_type ON events(event_type);
|
||||||
|
|
||||||
|
-- Create index on timestamp for faster date-based queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
|
||||||
|
|
||||||
|
-- Create index on user_id for faster user-based queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id);
|
||||||
|
'''))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Drop events table and its indexes
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
conn.execute(text('''
|
||||||
|
DROP INDEX IF EXISTS idx_events_event_type;
|
||||||
|
DROP INDEX IF EXISTS idx_events_timestamp;
|
||||||
|
DROP INDEX IF EXISTS idx_events_user_id;
|
||||||
|
DROP TABLE IF EXISTS events;
|
||||||
|
'''))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Use the same database configuration as in app.py
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'postgresql://postgres:1253@localhost:5432/docupulse')
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
|
print("Connecting to database...")
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
upgrade()
|
||||||
61
migrations/add_notifs_table.py
Normal file
61
migrations/add_notifs_table.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add the parent directory to Python path so we can import from root
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from extensions import db
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create notifs table
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
conn.execute(text('''
|
||||||
|
CREATE TABLE IF NOT EXISTS notifs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
notif_type VARCHAR(50) NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES "user" (id),
|
||||||
|
sender_id INTEGER REFERENCES "user" (id),
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
details JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for faster queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifs_notif_type ON notifs(notif_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifs_timestamp ON notifs(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifs_user_id ON notifs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifs_sender_id ON notifs(sender_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifs_read ON notifs(read);
|
||||||
|
'''))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Drop notifs table and its indexes
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
conn.execute(text('''
|
||||||
|
DROP INDEX IF EXISTS idx_notifs_notif_type;
|
||||||
|
DROP INDEX IF EXISTS idx_notifs_timestamp;
|
||||||
|
DROP INDEX IF EXISTS idx_notifs_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_notifs_sender_id;
|
||||||
|
DROP INDEX IF EXISTS idx_notifs_read;
|
||||||
|
DROP TABLE IF EXISTS notifs;
|
||||||
|
'''))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Use the same database configuration as in app.py
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'postgresql://postgres:1253@localhost:5432/docupulse')
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
|
print("Connecting to database...")
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
upgrade()
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""merge heads
|
|
||||||
|
|
||||||
Revision ID: 4ee23cb29001
|
|
||||||
Revises: 72ab6c4c6a5f, add_status_details
|
|
||||||
Create Date: 2025-06-09 10:04:48.708415
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '4ee23cb29001'
|
|
||||||
down_revision = ('72ab6c4c6a5f', 'add_status_details')
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
pass
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""merge heads
|
|
||||||
|
|
||||||
Revision ID: 72ab6c4c6a5f
|
|
||||||
Revises: 0a8006bd1732, add_docupulse_settings, add_manager_role, make_events_user_id_nullable
|
|
||||||
Create Date: 2025-06-05 14:21:46.046125
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '72ab6c4c6a5f'
|
|
||||||
down_revision = ('0a8006bd1732', 'add_docupulse_settings', 'add_manager_role', 'make_events_user_id_nullable')
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# Ensure is_manager column exists
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = inspect(conn)
|
|
||||||
columns = [col['name'] for col in inspector.get_columns('user')]
|
|
||||||
|
|
||||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
|
||||||
if 'is_manager' not in columns:
|
|
||||||
batch_op.add_column(sa.Column('is_manager', sa.Boolean(), nullable=True, server_default='false'))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
pass
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""merge heads
|
|
||||||
|
|
||||||
Revision ID: 761908f0cacf
|
|
||||||
Revises: 4ee23cb29001, add_connection_token
|
|
||||||
Create Date: 2025-06-09 13:57:17.650231
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '761908f0cacf'
|
|
||||||
down_revision = ('4ee23cb29001', 'add_connection_token')
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
pass
|
|
||||||
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.
@@ -1,24 +0,0 @@
|
|||||||
"""merge heads
|
|
||||||
|
|
||||||
Revision ID: a1fd98e6630d
|
|
||||||
Revises: 761908f0cacf, add_password_reset_tokens
|
|
||||||
Create Date: 2025-06-23 09:12:50.264151
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'a1fd98e6630d'
|
|
||||||
down_revision = ('761908f0cacf', 'add_password_reset_tokens')
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
pass
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""add connection_token column
|
|
||||||
|
|
||||||
Revision ID: add_connection_token
|
|
||||||
Revises: fix_updated_at_trigger
|
|
||||||
Create Date: 2024-03-19 13:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'add_connection_token'
|
|
||||||
down_revision = 'fix_updated_at_trigger'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# Get the inspector
|
|
||||||
inspector = inspect(op.get_bind())
|
|
||||||
|
|
||||||
# Check if the column exists
|
|
||||||
columns = [col['name'] for col in inspector.get_columns('instances')]
|
|
||||||
if 'connection_token' not in columns:
|
|
||||||
op.add_column('instances', sa.Column('connection_token', sa.String(64), nullable=True, unique=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_column('instances', 'connection_token')
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""add docupulse settings table
|
|
||||||
|
|
||||||
Revision ID: add_docupulse_settings
|
|
||||||
Revises: add_notifs_table
|
|
||||||
Create Date: 2024-03-19 10:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from datetime import datetime
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'add_docupulse_settings'
|
|
||||||
down_revision = 'add_notifs_table'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# Check if table exists
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'docupulse_settings' in inspector.get_table_names():
|
|
||||||
# Table exists, alter the max_storage column
|
|
||||||
op.alter_column('docupulse_settings', 'max_storage',
|
|
||||||
existing_type=sa.Integer(),
|
|
||||||
type_=sa.BigInteger(),
|
|
||||||
existing_nullable=False,
|
|
||||||
server_default='10737418240')
|
|
||||||
|
|
||||||
# Check if we need to insert default data
|
|
||||||
result = conn.execute(text("SELECT COUNT(*) FROM docupulse_settings")).scalar()
|
|
||||||
if result == 0:
|
|
||||||
conn.execute(text("""
|
|
||||||
INSERT INTO docupulse_settings (id, max_rooms, max_conversations, max_storage, updated_at)
|
|
||||||
VALUES (1, 10, 10, 10737418240, CURRENT_TIMESTAMP)
|
|
||||||
"""))
|
|
||||||
else:
|
|
||||||
# Create new table
|
|
||||||
op.create_table('docupulse_settings',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('max_rooms', sa.Integer(), nullable=False, server_default='10'),
|
|
||||||
sa.Column('max_conversations', sa.Integer(), nullable=False, server_default='10'),
|
|
||||||
sa.Column('max_storage', sa.BigInteger(), nullable=False, server_default='10737418240'), # 10GB in bytes
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert default settings
|
|
||||||
op.execute(text("""
|
|
||||||
INSERT INTO docupulse_settings (id, max_rooms, max_conversations, max_storage, updated_at)
|
|
||||||
VALUES (1, 10, 10, 10737418240, CURRENT_TIMESTAMP)
|
|
||||||
"""))
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_table('docupulse_settings')
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""create events table
|
|
||||||
|
|
||||||
Revision ID: add_events_table
|
|
||||||
Revises: f18735338888
|
|
||||||
Create Date: 2024-03-19 10:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'add_events_table'
|
|
||||||
down_revision = 'f18735338888'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = inspect(conn)
|
|
||||||
tables = inspector.get_table_names()
|
|
||||||
|
|
||||||
if 'events' not in tables:
|
|
||||||
op.create_table(
|
|
||||||
'events',
|
|
||||||
sa.Column('id', sa.Integer, primary_key=True),
|
|
||||||
sa.Column('event_type', sa.String(50), nullable=False),
|
|
||||||
sa.Column('user_id', sa.Integer, sa.ForeignKey('user.id'), nullable=True),
|
|
||||||
sa.Column('timestamp', sa.DateTime, nullable=False),
|
|
||||||
sa.Column('details', sa.JSON),
|
|
||||||
sa.Column('ip_address', sa.String(45)),
|
|
||||||
sa.Column('user_agent', sa.String(255)),
|
|
||||||
)
|
|
||||||
op.create_index('idx_events_event_type', 'events', ['event_type'])
|
|
||||||
op.create_index('idx_events_timestamp', 'events', ['timestamp'])
|
|
||||||
op.create_index('idx_events_user_id', 'events', ['user_id'])
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_index('idx_events_event_type', table_name='events')
|
|
||||||
op.drop_index('idx_events_timestamp', table_name='events')
|
|
||||||
op.drop_index('idx_events_user_id', table_name='events')
|
|
||||||
op.drop_table('events')
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""add instances table
|
|
||||||
|
|
||||||
Revision ID: add_instances_table
|
|
||||||
Revises:
|
|
||||||
Create Date: 2024-03-19 10:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'add_instances_table'
|
|
||||||
down_revision = None
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
conn = op.get_bind()
|
|
||||||
result = conn.execute(text("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_name = 'instances'
|
|
||||||
);
|
|
||||||
"""))
|
|
||||||
exists = result.scalar()
|
|
||||||
if not exists:
|
|
||||||
op.create_table('instances',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('name', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('company', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('rooms_count', sa.Integer(), nullable=False, server_default='0'),
|
|
||||||
sa.Column('conversations_count', sa.Integer(), nullable=False, server_default='0'),
|
|
||||||
sa.Column('data_size', sa.String(length=50), nullable=False, server_default='0 MB'),
|
|
||||||
sa.Column('payment_plan', sa.String(length=50), nullable=False, server_default='free'),
|
|
||||||
sa.Column('main_url', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('status', sa.String(length=20), nullable=False, server_default='inactive'),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('name'),
|
|
||||||
sa.UniqueConstraint('main_url')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a trigger to automatically update the updated_at column
|
|
||||||
op.execute("""
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ language 'plpgsql';
|
|
||||||
""")
|
|
||||||
|
|
||||||
op.execute("""
|
|
||||||
CREATE TRIGGER update_instances_updated_at
|
|
||||||
BEFORE UPDATE ON instances
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.execute("DROP TRIGGER IF EXISTS update_instances_updated_at ON instances")
|
|
||||||
op.execute("DROP FUNCTION IF EXISTS update_updated_at_column()")
|
|
||||||
op.drop_table('instances')
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""Add manager role
|
|
||||||
|
|
||||||
Revision ID: add_manager_role
|
|
||||||
Revises: 25da158dd705
|
|
||||||
Create Date: 2024-03-20 10:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'add_manager_role'
|
|
||||||
down_revision = '25da158dd705'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = inspect(conn)
|
|
||||||
columns = [col['name'] for col in inspector.get_columns('user')]
|
|
||||||
|
|
||||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
|
||||||
if 'is_manager' not in columns:
|
|
||||||
batch_op.add_column(sa.Column('is_manager', sa.Boolean(), nullable=True, server_default='false'))
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
|
||||||
batch_op.drop_column('is_manager')
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""create notifs table
|
|
||||||
|
|
||||||
Revision ID: add_notifs_table
|
|
||||||
Revises: add_events_table
|
|
||||||
Create Date: 2024-03-19 10:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'add_notifs_table'
|
|
||||||
down_revision = 'add_events_table'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = inspect(conn)
|
|
||||||
tables = inspector.get_table_names()
|
|
||||||
|
|
||||||
if 'notifs' not in tables:
|
|
||||||
op.create_table(
|
|
||||||
'notifs',
|
|
||||||
sa.Column('id', sa.Integer, primary_key=True),
|
|
||||||
sa.Column('notif_type', sa.String(50), nullable=False),
|
|
||||||
sa.Column('user_id', sa.Integer, sa.ForeignKey('user.id'), nullable=False),
|
|
||||||
sa.Column('sender_id', sa.Integer, sa.ForeignKey('user.id'), nullable=True),
|
|
||||||
sa.Column('timestamp', sa.DateTime, nullable=False),
|
|
||||||
sa.Column('read', sa.Boolean, nullable=False, default=False),
|
|
||||||
sa.Column('details', sa.JSON),
|
|
||||||
)
|
|
||||||
op.create_index('idx_notifs_notif_type', 'notifs', ['notif_type'])
|
|
||||||
op.create_index('idx_notifs_timestamp', 'notifs', ['timestamp'])
|
|
||||||
op.create_index('idx_notifs_user_id', 'notifs', ['user_id'])
|
|
||||||
op.create_index('idx_notifs_sender_id', 'notifs', ['sender_id'])
|
|
||||||
op.create_index('idx_notifs_read', 'notifs', ['read'])
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_index('idx_notifs_notif_type', table_name='notifs')
|
|
||||||
op.drop_index('idx_notifs_timestamp', table_name='notifs')
|
|
||||||
op.drop_index('idx_notifs_user_id', table_name='notifs')
|
|
||||||
op.drop_index('idx_notifs_sender_id', table_name='notifs')
|
|
||||||
op.drop_index('idx_notifs_read', table_name='notifs')
|
|
||||||
op.drop_table('notifs')
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""Add password reset tokens table
|
|
||||||
|
|
||||||
Revision ID: add_password_reset_tokens
|
|
||||||
Revises: be1f7bdd10e1
|
|
||||||
Create Date: 2024-01-01 12:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'add_password_reset_tokens'
|
|
||||||
down_revision = 'be1f7bdd10e1'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
conn = op.get_bind()
|
|
||||||
result = conn.execute(text("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_name = 'password_reset_tokens'
|
|
||||||
);
|
|
||||||
"""))
|
|
||||||
exists = result.scalar()
|
|
||||||
if not exists:
|
|
||||||
# Create password_reset_tokens table
|
|
||||||
op.create_table('password_reset_tokens',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('token', sa.String(length=100), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('used', sa.Boolean(), nullable=True),
|
|
||||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('token')
|
|
||||||
)
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
# Drop password_reset_tokens table
|
|
||||||
op.drop_table('password_reset_tokens')
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""add status_details column
|
|
||||||
|
|
||||||
Revision ID: add_status_details
|
|
||||||
Revises: add_instances_table
|
|
||||||
Create Date: 2024-03-19 11:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'add_status_details'
|
|
||||||
down_revision = 'add_instances_table'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# Check if column exists before adding
|
|
||||||
conn = op.get_bind()
|
|
||||||
result = conn.execute(text("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.columns
|
|
||||||
WHERE table_name = 'instances' AND column_name = 'status_details'
|
|
||||||
);
|
|
||||||
"""))
|
|
||||||
exists = result.scalar()
|
|
||||||
|
|
||||||
if not exists:
|
|
||||||
op.add_column('instances', sa.Column('status_details', sa.Text(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_column('instances', 'status_details')
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"""add version tracking fields to instances
|
|
||||||
|
|
||||||
Revision ID: c94c2b2b9f2e
|
|
||||||
Revises: a1fd98e6630d
|
|
||||||
Create Date: 2025-06-23 09:15:13.092801
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'c94c2b2b9f2e'
|
|
||||||
down_revision = 'a1fd98e6630d'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_table('email_template')
|
|
||||||
op.drop_table('notification')
|
|
||||||
with op.batch_alter_table('instances', schema=None) as batch_op:
|
|
||||||
batch_op.add_column(sa.Column('deployed_version', sa.String(length=50), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column('deployed_branch', sa.String(length=100), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column('latest_version', sa.String(length=50), nullable=True))
|
|
||||||
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'])
|
|
||||||
|
|
||||||
with op.batch_alter_table('instances', schema=None) as batch_op:
|
|
||||||
batch_op.drop_column('version_checked_at')
|
|
||||||
batch_op.drop_column('latest_version')
|
|
||||||
batch_op.drop_column('deployed_branch')
|
|
||||||
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 ###
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"""fix updated_at trigger
|
|
||||||
|
|
||||||
Revision ID: fix_updated_at_trigger
|
|
||||||
Revises: add_status_details
|
|
||||||
Create Date: 2024-03-19 12:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'fix_updated_at_trigger'
|
|
||||||
down_revision = 'add_status_details'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# Drop the existing trigger if it exists
|
|
||||||
op.execute("DROP TRIGGER IF EXISTS update_instances_updated_at ON instances")
|
|
||||||
op.execute("DROP FUNCTION IF EXISTS update_updated_at_column()")
|
|
||||||
|
|
||||||
# Create the trigger function
|
|
||||||
op.execute("""
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ language 'plpgsql';
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Create the trigger
|
|
||||||
op.execute("""
|
|
||||||
CREATE TRIGGER update_instances_updated_at
|
|
||||||
BEFORE UPDATE ON instances
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
""")
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
# Drop the trigger and function
|
|
||||||
op.execute("DROP TRIGGER IF EXISTS update_instances_updated_at ON instances")
|
|
||||||
op.execute("DROP FUNCTION IF EXISTS update_updated_at_column()")
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""make events user_id nullable
|
|
||||||
|
|
||||||
Revision ID: make_events_user_id_nullable
|
|
||||||
Revises: f18735338888
|
|
||||||
Create Date: 2024-03-19 10:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'make_events_user_id_nullable'
|
|
||||||
down_revision = 'f18735338888' # This should be the latest migration
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# Make user_id nullable in events table
|
|
||||||
op.alter_column('events', 'user_id',
|
|
||||||
existing_type=sa.Integer(),
|
|
||||||
nullable=True)
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
# Make user_id non-nullable again
|
|
||||||
op.alter_column('events', 'user_id',
|
|
||||||
existing_type=sa.Integer(),
|
|
||||||
nullable=False)
|
|
||||||
224
models.py
224
models.py
@@ -22,11 +22,10 @@ conversation_members = db.Table('conversation_members',
|
|||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||||
last_name = db.Column(db.String(150), nullable=False, default='--')
|
last_name = db.Column(db.String(150), nullable=False, default='(You)')
|
||||||
email = db.Column(db.String(150), unique=True, nullable=False)
|
email = db.Column(db.String(150), unique=True, nullable=False)
|
||||||
password_hash = db.Column(db.String(256))
|
password_hash = db.Column(db.String(256))
|
||||||
is_admin = db.Column(db.Boolean, default=False)
|
is_admin = db.Column(db.Boolean, default=False)
|
||||||
is_manager = db.Column(db.Boolean, default=False) # New field for manager role
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
phone = db.Column(db.String(20))
|
phone = db.Column(db.String(20))
|
||||||
company = db.Column(db.String(100))
|
company = db.Column(db.String(100))
|
||||||
@@ -35,11 +34,7 @@ class User(UserMixin, db.Model):
|
|||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
profile_picture = db.Column(db.String(255))
|
profile_picture = db.Column(db.String(255))
|
||||||
preferred_view = db.Column(db.String(10), default='grid', nullable=False) # 'grid' or 'list'
|
preferred_view = db.Column(db.String(10), default='grid', nullable=False) # 'grid' or 'list'
|
||||||
room_permissions = relationship(
|
room_permissions = relationship('RoomMemberPermission', back_populates='user')
|
||||||
'RoomMemberPermission',
|
|
||||||
back_populates='user',
|
|
||||||
cascade='all, delete-orphan'
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
self.password_hash = generate_password_hash(password)
|
self.password_hash = generate_password_hash(password)
|
||||||
@@ -55,10 +50,10 @@ class Room(db.Model):
|
|||||||
name = db.Column(db.String(100), nullable=False)
|
name = db.Column(db.String(100), nullable=False)
|
||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
creator = db.relationship('User', backref=db.backref('created_rooms', cascade='all, delete-orphan'), foreign_keys=[created_by])
|
creator = db.relationship('User', backref='created_rooms', foreign_keys=[created_by])
|
||||||
members = db.relationship('User', secondary=room_members, backref=db.backref('rooms', lazy='dynamic'))
|
members = db.relationship('User', secondary=room_members, backref=db.backref('rooms', lazy='dynamic'))
|
||||||
member_permissions = relationship('RoomMemberPermission', back_populates='room', cascade='all, delete-orphan')
|
member_permissions = relationship('RoomMemberPermission', back_populates='room', cascade='all, delete-orphan')
|
||||||
files = db.relationship('RoomFile', back_populates='room', cascade='all, delete-orphan')
|
files = db.relationship('RoomFile', back_populates='room', cascade='all, delete-orphan')
|
||||||
@@ -70,7 +65,7 @@ class Room(db.Model):
|
|||||||
class RoomMemberPermission(db.Model):
|
class RoomMemberPermission(db.Model):
|
||||||
__tablename__ = 'room_member_permissions'
|
__tablename__ = 'room_member_permissions'
|
||||||
room_id = db.Column(db.Integer, db.ForeignKey('room.id'), primary_key=True)
|
room_id = db.Column(db.Integer, db.ForeignKey('room.id'), primary_key=True)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), primary_key=True)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
|
||||||
can_view = db.Column(db.Boolean, default=True, nullable=False)
|
can_view = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
can_download = db.Column(db.Boolean, default=False, nullable=False)
|
can_download = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
can_upload = db.Column(db.Boolean, default=False, nullable=False)
|
can_upload = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
@@ -91,13 +86,13 @@ class RoomFile(db.Model):
|
|||||||
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
|
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
|
||||||
size = db.Column(db.Integer) # in bytes, null for folders
|
size = db.Column(db.Integer) # in bytes, null for folders
|
||||||
modified = db.Column(db.Float) # timestamp
|
modified = db.Column(db.Float) # timestamp
|
||||||
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'))
|
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
deleted = db.Column(db.Boolean, default=False) # New field for deleted status
|
deleted = db.Column(db.Boolean, default=False) # New field for deleted status
|
||||||
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'))
|
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id')) # New field for tracking who deleted the file
|
||||||
deleted_at = db.Column(db.DateTime) # New field for tracking when the file was deleted
|
deleted_at = db.Column(db.DateTime) # New field for tracking when the file was deleted
|
||||||
uploader = db.relationship('User', backref=db.backref('uploaded_files', cascade='all, delete-orphan'), foreign_keys=[uploaded_by])
|
uploader = db.relationship('User', backref='uploaded_files', foreign_keys=[uploaded_by])
|
||||||
deleter = db.relationship('User', backref=db.backref('deleted_room_files', cascade='all, delete-orphan'), foreign_keys=[deleted_by])
|
deleter = db.relationship('User', backref='deleted_room_files', foreign_keys=[deleted_by])
|
||||||
room = db.relationship('Room', back_populates='files')
|
room = db.relationship('Room', back_populates='files')
|
||||||
starred_by = db.relationship('User', secondary='user_starred_file', backref='starred_files')
|
starred_by = db.relationship('User', secondary='user_starred_file', backref='starred_files')
|
||||||
|
|
||||||
@@ -107,7 +102,7 @@ class RoomFile(db.Model):
|
|||||||
class UserStarredFile(db.Model):
|
class UserStarredFile(db.Model):
|
||||||
__tablename__ = 'user_starred_file'
|
__tablename__ = 'user_starred_file'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
file_id = db.Column(db.Integer, db.ForeignKey('room_file.id'), nullable=False)
|
file_id = db.Column(db.Integer, db.ForeignKey('room_file.id'), nullable=False)
|
||||||
starred_at = db.Column(db.DateTime, default=datetime.utcnow)
|
starred_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
@@ -128,13 +123,13 @@ class TrashedFile(db.Model):
|
|||||||
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
|
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
|
||||||
size = db.Column(db.Integer) # in bytes, null for folders
|
size = db.Column(db.Integer) # in bytes, null for folders
|
||||||
modified = db.Column(db.Float) # timestamp
|
modified = db.Column(db.Float) # timestamp
|
||||||
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'))
|
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
deleted_at = db.Column(db.DateTime, default=datetime.utcnow)
|
deleted_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
room = db.relationship('Room', backref='trashed_files')
|
room = db.relationship('Room', backref='trashed_files')
|
||||||
uploader = db.relationship('User', foreign_keys=[uploaded_by], backref=db.backref('uploaded_trashed_files', cascade='all, delete-orphan'))
|
uploader = db.relationship('User', foreign_keys=[uploaded_by], backref='uploaded_trashed_files')
|
||||||
deleter = db.relationship('User', foreign_keys=[deleted_by], backref=db.backref('deleted_trashed_files', cascade='all, delete-orphan'))
|
deleter = db.relationship('User', foreign_keys=[deleted_by], backref='deleted_trashed_files') # Changed from deleted_files to deleted_trashed_files
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<TrashedFile {self.name} ({self.type}) from {self.original_path}>'
|
return f'<TrashedFile {self.name} ({self.type}) from {self.original_path}>'
|
||||||
@@ -166,65 +161,6 @@ class SiteSettings(db.Model):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
class DocuPulseSettings(db.Model):
|
|
||||||
__tablename__ = 'docupulse_settings'
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
max_rooms = db.Column(db.Integer, default=10)
|
|
||||||
max_conversations = db.Column(db.Integer, default=10)
|
|
||||||
max_storage = db.Column(db.BigInteger, default=10737418240) # 10GB in bytes
|
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_settings(cls):
|
|
||||||
try:
|
|
||||||
settings = cls.query.first()
|
|
||||||
if not settings:
|
|
||||||
settings = cls(
|
|
||||||
max_rooms=10,
|
|
||||||
max_conversations=10,
|
|
||||||
max_storage=10737418240 # 10GB in bytes
|
|
||||||
)
|
|
||||||
db.session.add(settings)
|
|
||||||
db.session.commit()
|
|
||||||
return settings
|
|
||||||
except Exception as e:
|
|
||||||
# If there's an error (like integer overflow), rollback and return None
|
|
||||||
db.session.rollback()
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_usage_stats(cls):
|
|
||||||
settings = cls.get_settings()
|
|
||||||
if not settings:
|
|
||||||
# Return default values if settings can't be retrieved
|
|
||||||
return {
|
|
||||||
'max_rooms': 10,
|
|
||||||
'max_conversations': 10,
|
|
||||||
'max_storage': 10737418240,
|
|
||||||
'current_rooms': 0,
|
|
||||||
'current_conversations': 0,
|
|
||||||
'current_storage': 0,
|
|
||||||
'rooms_percentage': 0,
|
|
||||||
'conversations_percentage': 0,
|
|
||||||
'storage_percentage': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
total_rooms = Room.query.count()
|
|
||||||
total_conversations = Conversation.query.count()
|
|
||||||
total_storage = db.session.query(db.func.sum(RoomFile.size)).filter(RoomFile.deleted == False).scalar() or 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
'max_rooms': settings.max_rooms,
|
|
||||||
'max_conversations': settings.max_conversations,
|
|
||||||
'max_storage': settings.max_storage,
|
|
||||||
'current_rooms': total_rooms,
|
|
||||||
'current_conversations': total_conversations,
|
|
||||||
'current_storage': total_storage,
|
|
||||||
'rooms_percentage': (total_rooms / settings.max_rooms) * 100 if settings.max_rooms > 0 else 0,
|
|
||||||
'conversations_percentage': (total_conversations / settings.max_conversations) * 100 if settings.max_conversations > 0 else 0,
|
|
||||||
'storage_percentage': (total_storage / settings.max_storage) * 100 if settings.max_storage > 0 else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
class KeyValueSettings(db.Model):
|
class KeyValueSettings(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
key = db.Column(db.String(100), unique=True, nullable=False)
|
key = db.Column(db.String(100), unique=True, nullable=False)
|
||||||
@@ -261,10 +197,10 @@ class Conversation(db.Model):
|
|||||||
name = db.Column(db.String(100), nullable=False)
|
name = db.Column(db.String(100), nullable=False)
|
||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
creator = db.relationship('User', backref=db.backref('created_conversations', cascade='all, delete-orphan'), foreign_keys=[created_by])
|
creator = db.relationship('User', backref='created_conversations', foreign_keys=[created_by])
|
||||||
members = db.relationship('User', secondary=conversation_members, backref=db.backref('conversations', lazy='dynamic'))
|
members = db.relationship('User', secondary=conversation_members, backref=db.backref('conversations', lazy='dynamic'))
|
||||||
messages = db.relationship('Message', back_populates='conversation', cascade='all, delete-orphan')
|
messages = db.relationship('Message', back_populates='conversation', cascade='all, delete-orphan')
|
||||||
|
|
||||||
@@ -276,11 +212,11 @@ class Message(db.Model):
|
|||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id'), nullable=False)
|
conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id'), nullable=False)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
conversation = db.relationship('Conversation', back_populates='messages')
|
conversation = db.relationship('Conversation', back_populates='messages')
|
||||||
user = db.relationship('User', backref=db.backref('messages', cascade='all, delete-orphan'))
|
user = db.relationship('User', backref='messages')
|
||||||
attachments = db.relationship('MessageAttachment', back_populates='message', cascade='all, delete-orphan')
|
attachments = db.relationship('MessageAttachment', back_populates='message', cascade='all, delete-orphan')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -348,14 +284,14 @@ class Event(db.Model):
|
|||||||
__tablename__ = 'events'
|
__tablename__ = 'events'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
event_type = db.Column(db.String(50), nullable=False)
|
event_type = db.Column(db.String(50), nullable=False)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=True)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
details = db.Column(db.JSON) # Store additional event-specific data
|
details = db.Column(db.JSON) # Store additional event-specific data
|
||||||
ip_address = db.Column(db.String(45)) # IPv6 addresses can be up to 45 chars
|
ip_address = db.Column(db.String(45)) # IPv6 addresses can be up to 45 chars
|
||||||
user_agent = db.Column(db.String(255))
|
user_agent = db.Column(db.String(255))
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user = db.relationship('User', backref=db.backref('events', cascade='all, delete-orphan'))
|
user = db.relationship('User', backref='events')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Event {self.event_type} by User {self.user_id} at {self.timestamp}>'
|
return f'<Event {self.event_type} by User {self.user_id} at {self.timestamp}>'
|
||||||
@@ -380,14 +316,14 @@ class Notif(db.Model):
|
|||||||
__tablename__ = 'notifs'
|
__tablename__ = 'notifs'
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
notif_type = db.Column(db.String(50), nullable=False)
|
notif_type = db.Column(db.String(50), nullable=False)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
sender_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=True)
|
sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
read = db.Column(db.Boolean, default=False, nullable=False)
|
read = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
details = db.Column(db.JSON) # Store additional notification-specific data
|
details = db.Column(db.JSON) # Store additional notification-specific data
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('notifications', cascade='all, delete-orphan'))
|
user = db.relationship('User', foreign_keys=[user_id], backref='notifications')
|
||||||
sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_notifications')
|
sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_notifications')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -401,11 +337,11 @@ class EmailTemplate(db.Model):
|
|||||||
body = db.Column(db.Text, nullable=False)
|
body = db.Column(db.Text, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
creator = db.relationship('User', backref=db.backref('created_email_templates', cascade='all, delete-orphan'), foreign_keys=[created_by])
|
creator = db.relationship('User', backref='created_email_templates', foreign_keys=[created_by])
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<EmailTemplate {self.name}>'
|
return f'<EmailTemplate {self.name}>'
|
||||||
@@ -428,113 +364,3 @@ class Mail(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Mail to {self.recipient} status={self.status}>'
|
return f'<Mail to {self.recipient} status={self.status}>'
|
||||||
|
|
||||||
class PasswordSetupToken(db.Model):
|
|
||||||
__tablename__ = 'password_setup_tokens'
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
|
||||||
token = db.Column(db.String(100), unique=True, nullable=False)
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
||||||
expires_at = db.Column(db.DateTime, nullable=False)
|
|
||||||
used = db.Column(db.Boolean, default=False)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
user = db.relationship('User', backref=db.backref('password_setup_tokens', cascade='all, delete-orphan'))
|
|
||||||
|
|
||||||
def is_valid(self):
|
|
||||||
return not self.used and datetime.utcnow() < self.expires_at
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f'<PasswordSetupToken {self.token}>'
|
|
||||||
|
|
||||||
class PasswordResetToken(db.Model):
|
|
||||||
__tablename__ = 'password_reset_tokens'
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
|
||||||
token = db.Column(db.String(100), unique=True, nullable=False)
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
||||||
expires_at = db.Column(db.DateTime, nullable=False)
|
|
||||||
used = db.Column(db.Boolean, default=False)
|
|
||||||
ip_address = db.Column(db.String(45)) # Store IP address for security
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
user = db.relationship('User', backref=db.backref('password_reset_tokens', cascade='all, delete-orphan'))
|
|
||||||
|
|
||||||
def is_valid(self):
|
|
||||||
return not self.used and datetime.utcnow() < self.expires_at
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f'<PasswordResetToken {self.token}>'
|
|
||||||
|
|
||||||
def user_has_permission(room, perm_name):
|
|
||||||
"""
|
|
||||||
Check if the current user has a specific permission in a room.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room: Room object
|
|
||||||
perm_name: Name of the permission to check (e.g., 'can_view', 'can_upload')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if user has permission, False otherwise
|
|
||||||
"""
|
|
||||||
# Admin and manager users have all permissions
|
|
||||||
if current_user.is_admin or current_user.is_manager:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check if user is a member of the room
|
|
||||||
if current_user not in room.members:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Get user's permissions for this room
|
|
||||||
permission = RoomMemberPermission.query.filter_by(
|
|
||||||
room_id=room.id,
|
|
||||||
user_id=current_user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
# If no specific permissions are set, user only has view access
|
|
||||||
if not permission:
|
|
||||||
return perm_name == 'can_view'
|
|
||||||
|
|
||||||
# Check the specific permission
|
|
||||||
return getattr(permission, perm_name, False)
|
|
||||||
|
|
||||||
class ManagementAPIKey(db.Model):
|
|
||||||
__tablename__ = 'management_api_keys'
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
api_key = db.Column(db.String(100), unique=True, nullable=False)
|
|
||||||
name = db.Column(db.String(100), nullable=False) # Name/description of the management tool
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
||||||
last_used_at = db.Column(db.DateTime)
|
|
||||||
is_active = db.Column(db.Boolean, default=True)
|
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='SET NULL'))
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
creator = db.relationship('User', backref=db.backref('created_api_keys', cascade='all, delete-orphan'))
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f'<ManagementAPIKey {self.name}>'
|
|
||||||
|
|
||||||
class Instance(db.Model):
|
|
||||||
__tablename__ = 'instances'
|
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
|
||||||
company = db.Column(db.String(100), nullable=False)
|
|
||||||
rooms_count = db.Column(db.Integer, nullable=False, default=0)
|
|
||||||
conversations_count = db.Column(db.Integer, nullable=False, default=0)
|
|
||||||
data_size = db.Column(db.Float, nullable=False, default=0.0)
|
|
||||||
payment_plan = db.Column(db.String(20), nullable=False, default='Basic')
|
|
||||||
main_url = db.Column(db.String(255), unique=True, nullable=False)
|
|
||||||
status = db.Column(db.String(20), nullable=False, default='inactive')
|
|
||||||
status_details = db.Column(db.Text, nullable=True)
|
|
||||||
connection_token = db.Column(db.String(64), unique=True, nullable=True)
|
|
||||||
# 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}>'
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
Flask>=2.0.0
|
Flask>=2.0.0
|
||||||
Flask-SQLAlchemy>=3.0.0
|
Flask-SQLAlchemy>=3.0.0
|
||||||
Flask-Login>=0.6.0
|
Flask-Login>=0.6.0
|
||||||
Flask-Mail==0.9.1
|
|
||||||
Flask-Migrate>=4.0.0
|
|
||||||
Flask-WTF>=1.0.0
|
Flask-WTF>=1.0.0
|
||||||
email-validator==2.1.0.post1
|
Flask-Migrate>=4.0.0
|
||||||
python-dotenv>=0.19.0
|
|
||||||
Werkzeug>=2.0.0
|
|
||||||
SQLAlchemy>=1.4.0
|
SQLAlchemy>=1.4.0
|
||||||
alembic>=1.7.0
|
Werkzeug>=2.0.0
|
||||||
|
WTForms==3.1.1
|
||||||
|
python-dotenv>=0.19.0
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
requests>=2.31.0
|
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
|
email_validator==2.1.0.post1
|
||||||
|
celery>=5.3.0
|
||||||
|
redis>=4.5.0
|
||||||
|
alembic>=1.7.0
|
||||||
|
flower>=2.0.0
|
||||||
prometheus-client>=0.16.0
|
prometheus-client>=0.16.0
|
||||||
PyJWT>=2.8.0
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
from flask import Blueprint, Flask, render_template
|
from flask import Blueprint, Flask, render_template
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
from models import SiteSettings
|
from models import SiteSettings
|
||||||
import os
|
|
||||||
|
|
||||||
def init_app(app: Flask):
|
def init_app(app: Flask):
|
||||||
# Create blueprints
|
# Create blueprints
|
||||||
main_bp = Blueprint('main', __name__)
|
main_bp = Blueprint('main', __name__)
|
||||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||||
rooms_bp = Blueprint('rooms', __name__)
|
rooms_bp = Blueprint('rooms', __name__)
|
||||||
public_bp = Blueprint('public', __name__)
|
|
||||||
|
|
||||||
# Import and initialize routes
|
# Import and initialize routes
|
||||||
from .main import init_routes as init_main_routes
|
from .main import init_routes as init_main_routes
|
||||||
@@ -19,12 +17,10 @@ def init_app(app: Flask):
|
|||||||
from .admin import admin as admin_routes
|
from .admin import admin as admin_routes
|
||||||
from .email_templates import email_templates as email_templates_routes
|
from .email_templates import email_templates as email_templates_routes
|
||||||
from .user import user_bp as user_routes
|
from .user import user_bp as user_routes
|
||||||
from .public import init_public_routes
|
|
||||||
|
|
||||||
# Initialize routes
|
# Initialize routes
|
||||||
init_main_routes(main_bp)
|
init_main_routes(main_bp)
|
||||||
init_auth_routes(auth_bp)
|
init_auth_routes(auth_bp)
|
||||||
init_public_routes(public_bp)
|
|
||||||
|
|
||||||
# Add site_settings context processor to all blueprints
|
# Add site_settings context processor to all blueprints
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
@@ -32,15 +28,9 @@ def init_app(app: Flask):
|
|||||||
site_settings = SiteSettings.query.first()
|
site_settings = SiteSettings.query.first()
|
||||||
return dict(site_settings=site_settings)
|
return dict(site_settings=site_settings)
|
||||||
|
|
||||||
# Add MASTER environment variable to all templates
|
|
||||||
@app.context_processor
|
|
||||||
def inject_master():
|
|
||||||
return dict(is_master=os.environ.get('MASTER', 'false').lower() == 'true')
|
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(public_bp)
|
|
||||||
app.register_blueprint(rooms_routes)
|
app.register_blueprint(rooms_routes)
|
||||||
app.register_blueprint(contacts_routes)
|
app.register_blueprint(contacts_routes)
|
||||||
app.register_blueprint(conversations_routes)
|
app.register_blueprint(conversations_routes)
|
||||||
|
|||||||
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.
@@ -1,6 +1,6 @@
|
|||||||
from flask import Blueprint, jsonify
|
from flask import Blueprint, jsonify
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from models import db, Room, RoomFile, User, DocuPulseSettings
|
from models import db, Room, RoomFile, User
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ def sync_files():
|
|||||||
return jsonify({'error': 'Unauthorized'}), 403
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
try:
|
try:
|
||||||
DATA_ROOT = '/app/uploads/rooms'
|
DATA_ROOT = '/data/rooms'
|
||||||
admin_user = User.query.filter_by(is_admin=True).first()
|
admin_user = User.query.filter_by(is_admin=True).first()
|
||||||
if not admin_user:
|
if not admin_user:
|
||||||
return jsonify({'error': 'No admin user found'}), 500
|
return jsonify({'error': 'No admin user found'}), 500
|
||||||
@@ -73,7 +73,7 @@ def verify_db_state():
|
|||||||
return jsonify({'error': 'Unauthorized'}), 403
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
try:
|
try:
|
||||||
DATA_ROOT = '/app/uploads/rooms'
|
DATA_ROOT = '/data/rooms'
|
||||||
verification_results = {
|
verification_results = {
|
||||||
'rooms_checked': 0,
|
'rooms_checked': 0,
|
||||||
'files_in_db_not_fs': [],
|
'files_in_db_not_fs': [],
|
||||||
@@ -208,7 +208,7 @@ def cleanup_orphaned_records():
|
|||||||
return jsonify({'error': 'Unauthorized'}), 403
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
try:
|
try:
|
||||||
DATA_ROOT = '/app/uploads/rooms'
|
DATA_ROOT = '/data/rooms'
|
||||||
rooms = Room.query.all()
|
rooms = Room.query.all()
|
||||||
cleaned_records = []
|
cleaned_records = []
|
||||||
|
|
||||||
@@ -242,15 +242,3 @@ def cleanup_orphaned_records():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
@admin.route('/api/admin/usage-stats', methods=['GET'])
|
|
||||||
@login_required
|
|
||||||
def get_usage_stats():
|
|
||||||
if not current_user.is_admin:
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 403
|
|
||||||
|
|
||||||
try:
|
|
||||||
stats = DocuPulseSettings.get_usage_stats()
|
|
||||||
return jsonify(stats)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
from flask import Blueprint, jsonify, request, current_app, make_response, flash, redirect, url_for
|
|
||||||
from functools import wraps
|
|
||||||
from models import (
|
|
||||||
KeyValueSettings, User, Room, Conversation, RoomFile,
|
|
||||||
SiteSettings, DocuPulseSettings, Event, Mail, ManagementAPIKey, PasswordSetupToken, PasswordResetToken
|
|
||||||
)
|
|
||||||
from extensions import db, csrf
|
|
||||||
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
|
|
||||||
})
|
|
||||||
254
routes/auth.py
254
routes/auth.py
@@ -1,31 +1,18 @@
|
|||||||
from flask import render_template, request, flash, redirect, url_for, Blueprint, jsonify
|
from flask import render_template, request, flash, redirect, url_for, Blueprint, jsonify
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
from models import db, User, Notif, PasswordSetupToken, PasswordResetToken
|
from models import db, User, Notif
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from utils import log_event, create_notification, get_unread_count
|
from utils import log_event, create_notification, get_unread_count
|
||||||
from utils.notification import generate_mail_from_notification
|
|
||||||
import string
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__)
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
def require_password_change(f):
|
def require_password_change(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated and current_user.check_password('changeme'):
|
||||||
# Check if user has any valid password setup tokens
|
flash('Please change your password before continuing.', 'warning')
|
||||||
has_valid_token = PasswordSetupToken.query.filter_by(
|
return redirect(url_for('auth.change_password'))
|
||||||
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 f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
@@ -229,234 +216,3 @@ def init_routes(auth_bp):
|
|||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
return render_template('auth/change_password.html')
|
return render_template('auth/change_password.html')
|
||||||
|
|
||||||
@auth_bp.route('/setup-password/<token>', methods=['GET', 'POST'])
|
|
||||||
def setup_password(token):
|
|
||||||
# Find the token
|
|
||||||
setup_token = PasswordSetupToken.query.filter_by(token=token).first()
|
|
||||||
|
|
||||||
if not setup_token or not setup_token.is_valid():
|
|
||||||
flash('Invalid or expired password setup link. Please contact your administrator for a new link.', 'error')
|
|
||||||
return redirect(url_for('auth.login'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
password = request.form.get('password')
|
|
||||||
confirm_password = request.form.get('confirm_password')
|
|
||||||
|
|
||||||
if not password or not confirm_password:
|
|
||||||
flash('Please fill in all fields.', 'error')
|
|
||||||
return render_template('auth/setup_password.html')
|
|
||||||
|
|
||||||
if password != confirm_password:
|
|
||||||
flash('Passwords do not match.', 'error')
|
|
||||||
return render_template('auth/setup_password.html')
|
|
||||||
|
|
||||||
# Password requirements
|
|
||||||
if len(password) < 8:
|
|
||||||
flash('Password must be at least 8 characters long.', 'error')
|
|
||||||
return render_template('auth/setup_password.html')
|
|
||||||
|
|
||||||
if not any(c.isupper() for c in password):
|
|
||||||
flash('Password must contain at least one uppercase letter.', 'error')
|
|
||||||
return render_template('auth/setup_password.html')
|
|
||||||
|
|
||||||
if not any(c.islower() for c in password):
|
|
||||||
flash('Password must contain at least one lowercase letter.', 'error')
|
|
||||||
return render_template('auth/setup_password.html')
|
|
||||||
|
|
||||||
if not any(c.isdigit() for c in password):
|
|
||||||
flash('Password must contain at least one number.', 'error')
|
|
||||||
return render_template('auth/setup_password.html')
|
|
||||||
|
|
||||||
if not any(c in string.punctuation for c in password):
|
|
||||||
flash('Password must contain at least one special character.', 'error')
|
|
||||||
return render_template('auth/setup_password.html')
|
|
||||||
|
|
||||||
# Update user's password
|
|
||||||
user = setup_token.user
|
|
||||||
user.set_password(password)
|
|
||||||
|
|
||||||
# Mark token as used
|
|
||||||
setup_token.used = True
|
|
||||||
|
|
||||||
# Create password change notification
|
|
||||||
create_notification(
|
|
||||||
notif_type='password_changed',
|
|
||||||
user_id=user.id,
|
|
||||||
details={
|
|
||||||
'message': 'Your password has been set up successfully.',
|
|
||||||
'timestamp': datetime.utcnow().isoformat()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log password setup event
|
|
||||||
log_event(
|
|
||||||
event_type='user_update',
|
|
||||||
user_id=user.id,
|
|
||||||
details={
|
|
||||||
'user_id': user.id,
|
|
||||||
'user_name': f"{user.username} {user.last_name}",
|
|
||||||
'update_type': 'password_setup',
|
|
||||||
'success': True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Log the user in and redirect to dashboard
|
|
||||||
login_user(user)
|
|
||||||
flash('Password set up successfully! Welcome to DocuPulse.', 'success')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
return render_template('auth/setup_password.html')
|
|
||||||
|
|
||||||
@auth_bp.route('/forgot-password', methods=['GET', 'POST'])
|
|
||||||
def forgot_password():
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
email = request.form.get('email')
|
|
||||||
|
|
||||||
if not email:
|
|
||||||
flash('Please enter your email address.', 'error')
|
|
||||||
return render_template('auth/forgot_password.html')
|
|
||||||
|
|
||||||
# Check if user exists
|
|
||||||
user = User.query.filter_by(email=email).first()
|
|
||||||
|
|
||||||
if user:
|
|
||||||
# Generate a secure token
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
# Create password reset token
|
|
||||||
reset_token = PasswordResetToken(
|
|
||||||
user_id=user.id,
|
|
||||||
token=token,
|
|
||||||
expires_at=datetime.utcnow() + timedelta(hours=1), # 1 hour expiration
|
|
||||||
ip_address=request.remote_addr
|
|
||||||
)
|
|
||||||
db.session.add(reset_token)
|
|
||||||
|
|
||||||
# Create notification for password reset
|
|
||||||
notif = create_notification(
|
|
||||||
notif_type='password_reset',
|
|
||||||
user_id=user.id,
|
|
||||||
details={
|
|
||||||
'message': 'You requested a password reset. Click the link below to reset your password.',
|
|
||||||
'reset_link': url_for('auth.reset_password', token=token, _external=True),
|
|
||||||
'expiry_time': (datetime.utcnow() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S UTC'),
|
|
||||||
'ip_address': request.remote_addr,
|
|
||||||
'timestamp': datetime.utcnow().isoformat()
|
|
||||||
},
|
|
||||||
generate_mail=False # Don't auto-generate email, we'll do it manually
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate and send email manually
|
|
||||||
if notif:
|
|
||||||
generate_mail_from_notification(notif)
|
|
||||||
|
|
||||||
# Log the password reset request
|
|
||||||
log_event(
|
|
||||||
event_type='user_update',
|
|
||||||
details={
|
|
||||||
'user_id': user.id,
|
|
||||||
'user_name': f"{user.username} {user.last_name}",
|
|
||||||
'email': user.email,
|
|
||||||
'update_type': 'password_reset_request',
|
|
||||||
'ip_address': request.remote_addr,
|
|
||||||
'success': True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Always show success message to prevent email enumeration
|
|
||||||
flash('If an account with that email exists, a password reset link has been sent to your email address.', 'success')
|
|
||||||
return redirect(url_for('auth.login'))
|
|
||||||
|
|
||||||
return render_template('auth/forgot_password.html')
|
|
||||||
|
|
||||||
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
|
||||||
def reset_password(token):
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
# Find the token
|
|
||||||
reset_token = PasswordResetToken.query.filter_by(token=token).first()
|
|
||||||
|
|
||||||
if not reset_token or not reset_token.is_valid():
|
|
||||||
flash('Invalid or expired password reset link. Please request a new password reset.', 'error')
|
|
||||||
return redirect(url_for('auth.forgot_password'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
password = request.form.get('password')
|
|
||||||
confirm_password = request.form.get('confirm_password')
|
|
||||||
|
|
||||||
if not password or not confirm_password:
|
|
||||||
flash('Please fill in all fields.', 'error')
|
|
||||||
return render_template('auth/reset_password.html', token=token)
|
|
||||||
|
|
||||||
if password != confirm_password:
|
|
||||||
flash('Passwords do not match.', 'error')
|
|
||||||
return render_template('auth/reset_password.html', token=token)
|
|
||||||
|
|
||||||
# Password requirements
|
|
||||||
if len(password) < 8:
|
|
||||||
flash('Password must be at least 8 characters long.', 'error')
|
|
||||||
return render_template('auth/reset_password.html', token=token)
|
|
||||||
|
|
||||||
if not any(c.isupper() for c in password):
|
|
||||||
flash('Password must contain at least one uppercase letter.', 'error')
|
|
||||||
return render_template('auth/reset_password.html', token=token)
|
|
||||||
|
|
||||||
if not any(c.islower() for c in password):
|
|
||||||
flash('Password must contain at least one lowercase letter.', 'error')
|
|
||||||
return render_template('auth/reset_password.html', token=token)
|
|
||||||
|
|
||||||
if not any(c.isdigit() for c in password):
|
|
||||||
flash('Password must contain at least one number.', 'error')
|
|
||||||
return render_template('auth/reset_password.html', token=token)
|
|
||||||
|
|
||||||
if not any(c in string.punctuation for c in password):
|
|
||||||
flash('Password must contain at least one special character.', 'error')
|
|
||||||
return render_template('auth/reset_password.html', token=token)
|
|
||||||
|
|
||||||
# Update user's password
|
|
||||||
user = reset_token.user
|
|
||||||
user.set_password(password)
|
|
||||||
|
|
||||||
# Mark token as used
|
|
||||||
reset_token.used = True
|
|
||||||
|
|
||||||
# Create password change notification
|
|
||||||
create_notification(
|
|
||||||
notif_type='password_changed',
|
|
||||||
user_id=user.id,
|
|
||||||
details={
|
|
||||||
'message': 'Your password has been reset successfully.',
|
|
||||||
'timestamp': datetime.utcnow().isoformat()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log password reset event
|
|
||||||
log_event(
|
|
||||||
event_type='user_update',
|
|
||||||
details={
|
|
||||||
'user_id': user.id,
|
|
||||||
'user_name': f"{user.username} {user.last_name}",
|
|
||||||
'email': user.email,
|
|
||||||
'update_type': 'password_reset',
|
|
||||||
'ip_address': request.remote_addr,
|
|
||||||
'success': True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Log the user in and redirect to dashboard
|
|
||||||
login_user(user)
|
|
||||||
flash('Password reset successfully! Welcome back to DocuPulse.', 'success')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
return render_template('auth/reset_password.html', token=token)
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from models import db, User, Notif, PasswordSetupToken
|
from models import db, User, Notif
|
||||||
from forms import UserForm
|
from forms import UserForm
|
||||||
from flask import abort
|
from flask import abort
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
@@ -9,13 +9,11 @@ from utils import log_event, create_notification, get_unread_count
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
|
|
||||||
contacts_bp = Blueprint('contacts', __name__, url_prefix='/contacts')
|
contacts_bp = Blueprint('contacts', __name__, url_prefix='/contacts')
|
||||||
|
|
||||||
UPLOAD_FOLDER = '/app/uploads/profile_pics'
|
UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads', 'profile_pics')
|
||||||
if not os.path.exists(UPLOAD_FOLDER):
|
if not os.path.exists(UPLOAD_FOLDER):
|
||||||
os.makedirs(UPLOAD_FOLDER)
|
os.makedirs(UPLOAD_FOLDER)
|
||||||
|
|
||||||
@@ -29,8 +27,8 @@ def inject_unread_notifications():
|
|||||||
def admin_required():
|
def admin_required():
|
||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
if not (current_user.is_admin or current_user.is_manager):
|
if not current_user.is_admin:
|
||||||
flash('You must be an admin or manager to access this page.', 'error')
|
flash('You must be an admin to access this page.', 'error')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
@contacts_bp.route('/')
|
@contacts_bp.route('/')
|
||||||
@@ -72,10 +70,8 @@ def contacts_list():
|
|||||||
# Apply role filter
|
# Apply role filter
|
||||||
if role == 'admin':
|
if role == 'admin':
|
||||||
query = query.filter(User.is_admin == True)
|
query = query.filter(User.is_admin == True)
|
||||||
elif role == 'manager':
|
|
||||||
query = query.filter(User.is_manager == True)
|
|
||||||
elif role == 'user':
|
elif role == 'user':
|
||||||
query = query.filter(User.is_admin == False, User.is_manager == False)
|
query = query.filter(User.is_admin == False)
|
||||||
|
|
||||||
# Order by creation date
|
# Order by creation date
|
||||||
query = query.order_by(User.created_at.desc())
|
query = query.order_by(User.created_at.desc())
|
||||||
@@ -98,61 +94,73 @@ def new_contact():
|
|||||||
form = UserForm()
|
form = UserForm()
|
||||||
total_admins = User.query.filter_by(is_admin=True).count()
|
total_admins = User.query.filter_by(is_admin=True).count()
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
form.role.data = 'user' # Default to standard user
|
form.is_admin.data = False # Ensure admin role is unchecked by default
|
||||||
elif request.method == 'POST':
|
elif request.method == 'POST' and 'is_admin' not in request.form:
|
||||||
if form.validate_on_submit():
|
form.is_admin.data = False # Explicitly set to False if not present in POST
|
||||||
# Check if a user with this email already exists
|
if form.validate_on_submit():
|
||||||
existing_user = User.query.filter_by(email=form.email.data).first()
|
# Check if a user with this email already exists
|
||||||
if existing_user:
|
existing_user = User.query.filter_by(email=form.email.data).first()
|
||||||
flash('A user with this email already exists.', 'error')
|
if existing_user:
|
||||||
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
|
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
|
# Handle profile picture upload
|
||||||
profile_picture = None
|
profile_picture = None
|
||||||
file = request.files.get('profile_picture')
|
file = request.files.get('profile_picture')
|
||||||
if file and file.filename:
|
if file and file.filename:
|
||||||
filename = secure_filename(file.filename)
|
filename = secure_filename(file.filename)
|
||||||
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||||
file.save(file_path)
|
file.save(file_path)
|
||||||
profile_picture = filename
|
profile_picture = filename
|
||||||
|
|
||||||
# Generate a random password
|
# Create new user account
|
||||||
alphabet = string.ascii_letters + string.digits + string.punctuation
|
user = User(
|
||||||
random_password = ''.join(secrets.choice(alphabet) for _ in range(32))
|
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()
|
||||||
|
|
||||||
# Create new user
|
# Create notification for the new user
|
||||||
user = User(
|
create_notification(
|
||||||
username=form.first_name.data,
|
notif_type='account_created',
|
||||||
last_name=form.last_name.data,
|
user_id=user.id,
|
||||||
email=form.email.data,
|
sender_id=current_user.id, # Admin who created the account
|
||||||
phone=form.phone.data,
|
details={
|
||||||
company=form.company.data,
|
'message': 'Your DocuPulse account has been created by an administrator.',
|
||||||
position=form.position.data,
|
'username': user.username,
|
||||||
notes=form.notes.data,
|
'email': user.email,
|
||||||
is_admin=(form.role.data == 'admin'),
|
'created_by': f"{current_user.username} {current_user.last_name}",
|
||||||
is_manager=(form.role.data == 'manager'),
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
profile_picture=profile_picture
|
}
|
||||||
)
|
)
|
||||||
user.set_password(random_password)
|
|
||||||
db.session.add(user)
|
|
||||||
|
|
||||||
# Log user creation event
|
# Log user creation event
|
||||||
log_event(
|
log_event(
|
||||||
event_type='user_create',
|
event_type='user_create',
|
||||||
details={
|
details={
|
||||||
'created_by': current_user.id,
|
'created_by': current_user.id,
|
||||||
'created_by_name': f"{current_user.username} {current_user.last_name}",
|
'created_by_name': f"{current_user.username} {current_user.last_name}",
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
'user_name': f"{user.username} {user.last_name}",
|
'user_name': f"{user.username} {user.last_name}",
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'role': form.role.data,
|
'is_admin': user.is_admin,
|
||||||
'method': 'admin_creation'
|
'method': 'admin_creation'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash('User created successfully! They will receive an email with a link to set up their password.', 'success')
|
flash('User created successfully! They will need to change their password on first login.', 'success')
|
||||||
return redirect(url_for('contacts.contacts_list'))
|
return redirect(url_for('contacts.contacts_list'))
|
||||||
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
|
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
|
||||||
|
|
||||||
@contacts_bp.route('/profile/edit', methods=['GET', 'POST'])
|
@contacts_bp.route('/profile/edit', methods=['GET', 'POST'])
|
||||||
@@ -253,13 +261,7 @@ def edit_contact(id):
|
|||||||
form.company.data = user.company
|
form.company.data = user.company
|
||||||
form.position.data = user.position
|
form.position.data = user.position
|
||||||
form.notes.data = user.notes
|
form.notes.data = user.notes
|
||||||
# Set role based on current permissions
|
form.is_admin.data = user.is_admin
|
||||||
if user.is_admin:
|
|
||||||
form.role.data = 'admin'
|
|
||||||
elif user.is_manager:
|
|
||||||
form.role.data = 'manager'
|
|
||||||
else:
|
|
||||||
form.role.data = 'user'
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
# Handle profile picture removal
|
# Handle profile picture removal
|
||||||
if 'remove_picture' in request.form:
|
if 'remove_picture' in request.form:
|
||||||
@@ -287,10 +289,9 @@ def edit_contact(id):
|
|||||||
user.profile_picture = filename
|
user.profile_picture = filename
|
||||||
|
|
||||||
# Prevent removing admin from the last admin
|
# Prevent removing admin from the last admin
|
||||||
if form.role.data != 'admin' and user.is_admin and total_admins <= 1:
|
if not form.is_admin.data and user.is_admin and total_admins <= 1:
|
||||||
flash('There must be at least one admin user in the system.', 'error')
|
flash('There must be at least one admin user in the system.', 'error')
|
||||||
return render_template('contacts/form.html', form=form, title='Edit User', total_admins=total_admins, user=user)
|
return render_template('contacts/form.html', form=form, title='Edit User', total_admins=total_admins, user=user)
|
||||||
|
|
||||||
# Check if the new email is already used by another user
|
# Check if the new email is already used by another user
|
||||||
if form.email.data != user.email:
|
if form.email.data != user.email:
|
||||||
existing_user = User.query.filter_by(email=form.email.data).first()
|
existing_user = User.query.filter_by(email=form.email.data).first()
|
||||||
@@ -305,7 +306,7 @@ def edit_contact(id):
|
|||||||
'phone': user.phone,
|
'phone': user.phone,
|
||||||
'company': user.company,
|
'company': user.company,
|
||||||
'position': user.position,
|
'position': user.position,
|
||||||
'role': 'admin' if user.is_admin else 'manager' if user.is_manager else 'user'
|
'is_admin': user.is_admin
|
||||||
}
|
}
|
||||||
|
|
||||||
user.username = form.first_name.data
|
user.username = form.first_name.data
|
||||||
@@ -315,8 +316,7 @@ def edit_contact(id):
|
|||||||
user.company = form.company.data
|
user.company = form.company.data
|
||||||
user.position = form.position.data
|
user.position = form.position.data
|
||||||
user.notes = form.notes.data
|
user.notes = form.notes.data
|
||||||
user.is_admin = (form.role.data == 'admin')
|
user.is_admin = form.is_admin.data
|
||||||
user.is_manager = (form.role.data == 'manager')
|
|
||||||
|
|
||||||
# Set password if provided
|
# Set password if provided
|
||||||
password_changed = False
|
password_changed = False
|
||||||
@@ -340,7 +340,6 @@ def edit_contact(id):
|
|||||||
'phone': user.phone,
|
'phone': user.phone,
|
||||||
'company': user.company,
|
'company': user.company,
|
||||||
'position': user.position,
|
'position': user.position,
|
||||||
'role': form.role.data,
|
|
||||||
'password_changed': password_changed
|
'password_changed': password_changed
|
||||||
},
|
},
|
||||||
'timestamp': datetime.utcnow().isoformat()
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
@@ -362,7 +361,7 @@ def edit_contact(id):
|
|||||||
'phone': user.phone,
|
'phone': user.phone,
|
||||||
'company': user.company,
|
'company': user.company,
|
||||||
'position': user.position,
|
'position': user.position,
|
||||||
'role': form.role.data
|
'is_admin': user.is_admin
|
||||||
},
|
},
|
||||||
'password_changed': password_changed,
|
'password_changed': password_changed,
|
||||||
'method': 'admin_update'
|
'method': 'admin_update'
|
||||||
@@ -448,53 +447,3 @@ def toggle_active(id):
|
|||||||
|
|
||||||
flash(f'User marked as {"active" if user.is_active else "inactive"}!', 'success')
|
flash(f'User marked as {"active" if user.is_active else "inactive"}!', 'success')
|
||||||
return redirect(url_for('contacts.contacts_list'))
|
return redirect(url_for('contacts.contacts_list'))
|
||||||
|
|
||||||
@contacts_bp.route('/<int:id>/resend-setup', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
@require_password_change
|
|
||||||
def resend_setup_link(id):
|
|
||||||
result = admin_required()
|
|
||||||
if result: return result
|
|
||||||
|
|
||||||
user = User.query.get_or_404(id)
|
|
||||||
|
|
||||||
# Create new password setup token
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
setup_token = PasswordSetupToken(
|
|
||||||
user_id=user.id,
|
|
||||||
token=token,
|
|
||||||
expires_at=datetime.utcnow() + timedelta(hours=24)
|
|
||||||
)
|
|
||||||
db.session.add(setup_token)
|
|
||||||
|
|
||||||
# Create notification for the user
|
|
||||||
create_notification(
|
|
||||||
notif_type='account_created',
|
|
||||||
user_id=user.id,
|
|
||||||
sender_id=current_user.id,
|
|
||||||
details={
|
|
||||||
'message': 'A new password setup link has been sent to you.',
|
|
||||||
'username': user.username,
|
|
||||||
'email': user.email,
|
|
||||||
'created_by': f"{current_user.username} {current_user.last_name}",
|
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
|
||||||
'setup_link': url_for('auth.setup_password', token=token, _external=True)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log the event
|
|
||||||
log_event(
|
|
||||||
event_type='user_update',
|
|
||||||
details={
|
|
||||||
'user_id': user.id,
|
|
||||||
'user_name': f"{user.username} {user.last_name}",
|
|
||||||
'updated_by': current_user.id,
|
|
||||||
'updated_by_name': f"{current_user.username} {current_user.last_name}",
|
|
||||||
'update_type': 'password_setup_link_resend'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash('Password setup link has been resent to the user.', 'success')
|
|
||||||
return redirect(url_for('contacts.contacts_list'))
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from models import db, Conversation, User, Message, MessageAttachment, DocuPulseSettings
|
from models import db, Conversation, User, Message, MessageAttachment
|
||||||
from forms import ConversationForm
|
from forms import ConversationForm
|
||||||
from routes.auth import require_password_change
|
from routes.auth import require_password_change
|
||||||
from utils import log_event, create_notification, get_unread_count
|
from utils import log_event, create_notification, get_unread_count
|
||||||
@@ -55,15 +55,14 @@ def conversations():
|
|||||||
query = query.filter(Conversation.name.ilike(f'%{search}%'))
|
query = query.filter(Conversation.name.ilike(f'%{search}%'))
|
||||||
conversations = query.order_by(Conversation.created_at.desc()).all()
|
conversations = query.order_by(Conversation.created_at.desc()).all()
|
||||||
unread_count = get_unread_count(current_user.id)
|
unread_count = get_unread_count(current_user.id)
|
||||||
usage_stats = DocuPulseSettings.get_usage_stats()
|
return render_template('conversations/conversations.html', conversations=conversations, search=search, unread_notifications=unread_count)
|
||||||
return render_template('conversations/conversations.html', conversations=conversations, search=search, unread_notifications=unread_count, usage_stats=usage_stats)
|
|
||||||
|
|
||||||
@conversations_bp.route('/create', methods=['GET', 'POST'])
|
@conversations_bp.route('/create', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@require_password_change
|
@require_password_change
|
||||||
def create_conversation():
|
def create_conversation():
|
||||||
if not (current_user.is_admin or current_user.is_manager):
|
if not current_user.is_admin:
|
||||||
flash('Only administrators and managers can create conversations.', 'error')
|
flash('Only administrators can create conversations.', 'error')
|
||||||
return redirect(url_for('conversations.conversations'))
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
|
||||||
form = ConversationForm()
|
form = ConversationForm()
|
||||||
@@ -149,8 +148,8 @@ def conversation(conversation_id):
|
|||||||
# Query messages directly using the Message model
|
# Query messages directly using the Message model
|
||||||
messages = Message.query.filter_by(conversation_id=conversation_id).order_by(Message.created_at.asc()).all()
|
messages = Message.query.filter_by(conversation_id=conversation_id).order_by(Message.created_at.asc()).all()
|
||||||
|
|
||||||
# Get all users for member selection (needed for admin and manager)
|
# Get all users for member selection (only needed for admin)
|
||||||
all_users = User.query.all() if (current_user.is_admin or current_user.is_manager) else None
|
all_users = User.query.all() if current_user.is_admin else None
|
||||||
|
|
||||||
unread_count = get_unread_count(current_user.id)
|
unread_count = get_unread_count(current_user.id)
|
||||||
return render_template('conversations/conversation.html',
|
return render_template('conversations/conversation.html',
|
||||||
@@ -168,8 +167,8 @@ def conversation_members(conversation_id):
|
|||||||
flash('You do not have access to this conversation.', 'error')
|
flash('You do not have access to this conversation.', 'error')
|
||||||
return redirect(url_for('conversations.conversations'))
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
|
||||||
if not (current_user.is_admin or current_user.is_manager):
|
if not current_user.is_admin:
|
||||||
flash('Only administrators and managers can manage conversation members.', 'error')
|
flash('Only administrators can manage conversation members.', 'error')
|
||||||
return redirect(url_for('conversations.conversation', conversation_id=conversation_id))
|
return redirect(url_for('conversations.conversation', conversation_id=conversation_id))
|
||||||
|
|
||||||
available_users = User.query.filter(~User.id.in_([m.id for m in conversation.members])).all()
|
available_users = User.query.filter(~User.id.in_([m.id for m in conversation.members])).all()
|
||||||
|
|||||||
1872
routes/launch_api.py
1872
routes/launch_api.py
File diff suppressed because it is too large
Load Diff
1092
routes/main.py
1092
routes/main.py
File diff suppressed because it is too large
Load Diff
@@ -1,79 +0,0 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for
|
|
||||||
from models import SiteSettings
|
|
||||||
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.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('/blog')
|
|
||||||
def blog():
|
|
||||||
"""Blog page"""
|
|
||||||
return render_template('public/blog.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('/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('/gdpr')
|
|
||||||
def gdpr():
|
|
||||||
"""GDPR page"""
|
|
||||||
return render_template('public/gdpr.html')
|
|
||||||
|
|
||||||
@public_bp.route('/compliance')
|
|
||||||
def compliance():
|
|
||||||
"""Compliance page"""
|
|
||||||
return render_template('public/compliance.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')
|
room_files_bp = Blueprint('room_files', __name__, url_prefix='/api/rooms')
|
||||||
|
|
||||||
# Root directory for storing room files
|
# Root directory for storing room files
|
||||||
DATA_ROOT = '/app/uploads/rooms' # Updated to match Docker volume mount point
|
DATA_ROOT = '/data/rooms' # This should be a Docker volume
|
||||||
|
|
||||||
# Set of allowed file extensions for upload
|
# Set of allowed file extensions for upload
|
||||||
ALLOWED_EXTENSIONS = {
|
ALLOWED_EXTENSIONS = {
|
||||||
@@ -217,6 +217,7 @@ def upload_room_file(room_id):
|
|||||||
|
|
||||||
# If we are overwriting, delete the trashed file record
|
# If we are overwriting, delete the trashed file record
|
||||||
db.session.delete(trashed_file)
|
db.session.delete(trashed_file)
|
||||||
|
db.session.commit()
|
||||||
existing_file = None
|
existing_file = None
|
||||||
|
|
||||||
file.save(file_path)
|
file.save(file_path)
|
||||||
@@ -346,19 +347,6 @@ def delete_file(room_id, filename):
|
|||||||
if not rf:
|
if not rf:
|
||||||
return jsonify({'error': 'File not found'}), 404
|
return jsonify({'error': 'File not found'}), 404
|
||||||
|
|
||||||
# If it's a folder, mark all contained items as deleted
|
|
||||||
if rf.type == 'folder':
|
|
||||||
folder_path = os.path.join(rf.path, rf.name) if rf.path else rf.name
|
|
||||||
contained_items = RoomFile.query.filter(
|
|
||||||
RoomFile.room_id == room_id,
|
|
||||||
RoomFile.path.like(f"{folder_path}%")
|
|
||||||
).all()
|
|
||||||
|
|
||||||
for item in contained_items:
|
|
||||||
item.deleted = True
|
|
||||||
item.deleted_by = current_user.id
|
|
||||||
item.deleted_at = datetime.utcnow()
|
|
||||||
|
|
||||||
# Mark as deleted and record who deleted it and when
|
# Mark as deleted and record who deleted it and when
|
||||||
rf.deleted = True
|
rf.deleted = True
|
||||||
rf.deleted_by = current_user.id
|
rf.deleted_by = current_user.id
|
||||||
@@ -1065,9 +1053,6 @@ def delete_permanent(room_id):
|
|||||||
for item in contained_items:
|
for item in contained_items:
|
||||||
db.session.delete(item)
|
db.session.delete(item)
|
||||||
|
|
||||||
# Delete the database record
|
|
||||||
db.session.delete(rf)
|
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
event_type='file_delete_permanent',
|
event_type='file_delete_permanent',
|
||||||
details={
|
details={
|
||||||
@@ -1081,15 +1066,10 @@ def delete_permanent(room_id):
|
|||||||
user_id=current_user.id
|
user_id=current_user.id
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error deleting file {rf.name}: {str(e)}")
|
print(f"Error deleting {rf.type} from storage: {e}")
|
||||||
continue
|
|
||||||
|
|
||||||
# Commit all changes
|
# Delete the database record
|
||||||
try:
|
db.session.delete(rf)
|
||||||
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})
|
return jsonify({'success': True})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from models import db, Room, User, RoomMemberPermission, RoomFile, Notif, DocuPulseSettings
|
from models import db, Room, User, RoomMemberPermission, RoomFile, Notif
|
||||||
from forms import RoomForm
|
from forms import RoomForm
|
||||||
from routes.room_files import user_has_permission
|
from routes.room_files import user_has_permission
|
||||||
from routes.auth import require_password_change
|
from routes.auth import require_password_change
|
||||||
@@ -36,11 +36,7 @@ def rooms():
|
|||||||
if search:
|
if search:
|
||||||
query = query.filter(Room.name.ilike(f'%{search}%'))
|
query = query.filter(Room.name.ilike(f'%{search}%'))
|
||||||
rooms = query.order_by(Room.created_at.desc()).all()
|
rooms = query.order_by(Room.created_at.desc()).all()
|
||||||
|
return render_template('rooms/rooms.html', rooms=rooms, search=search)
|
||||||
# Get usage stats
|
|
||||||
usage_stats = DocuPulseSettings.get_usage_stats()
|
|
||||||
|
|
||||||
return render_template('rooms/rooms.html', rooms=rooms, search=search, usage_stats=usage_stats)
|
|
||||||
|
|
||||||
@rooms_bp.route('/create', methods=['GET', 'POST'])
|
@rooms_bp.route('/create', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -89,7 +85,7 @@ def create_room():
|
|||||||
@require_password_change
|
@require_password_change
|
||||||
def room(room_id):
|
def room(room_id):
|
||||||
room = Room.query.get_or_404(room_id)
|
room = Room.query.get_or_404(room_id)
|
||||||
# Admins always have access, managers need to be members
|
# Admins always have access
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
||||||
if not is_member:
|
if not is_member:
|
||||||
@@ -120,15 +116,14 @@ def room(room_id):
|
|||||||
@require_password_change
|
@require_password_change
|
||||||
def room_members(room_id):
|
def room_members(room_id):
|
||||||
room = Room.query.get_or_404(room_id)
|
room = Room.query.get_or_404(room_id)
|
||||||
# Check if user is a member
|
# Admins always have access
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
||||||
if not is_member:
|
if not is_member:
|
||||||
flash('You do not have access to this room.', 'error')
|
flash('You do not have access to this room.', 'error')
|
||||||
return redirect(url_for('rooms.rooms'))
|
return redirect(url_for('rooms.rooms'))
|
||||||
# Only admins and managers who are members can manage room members
|
if not current_user.is_admin:
|
||||||
if not (current_user.is_admin or (current_user.is_manager and is_member)):
|
flash('Only administrators can manage room members.', 'error')
|
||||||
flash('Only administrators and managers can manage room members.', 'error')
|
|
||||||
return redirect(url_for('rooms.room', room_id=room_id))
|
return redirect(url_for('rooms.room', room_id=room_id))
|
||||||
member_permissions = {p.user_id: p for p in room.member_permissions}
|
member_permissions = {p.user_id: p for p in room.member_permissions}
|
||||||
available_users = User.query.filter(~User.id.in_(member_permissions.keys())).all()
|
available_users = User.query.filter(~User.id.in_(member_permissions.keys())).all()
|
||||||
@@ -144,9 +139,8 @@ def add_member(room_id):
|
|||||||
if not is_member:
|
if not is_member:
|
||||||
flash('You do not have access to this room.', 'error')
|
flash('You do not have access to this room.', 'error')
|
||||||
return redirect(url_for('rooms.rooms'))
|
return redirect(url_for('rooms.rooms'))
|
||||||
# Only admins and managers who are members can manage room members
|
if not current_user.is_admin:
|
||||||
if not (current_user.is_admin or (current_user.is_manager and is_member)):
|
flash('Only administrators can manage room members.', 'error')
|
||||||
flash('Only administrators and managers can manage room members.', 'error')
|
|
||||||
return redirect(url_for('rooms.room', room_id=room_id))
|
return redirect(url_for('rooms.room', room_id=room_id))
|
||||||
user_id = request.form.get('user_id')
|
user_id = request.form.get('user_id')
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@@ -217,30 +211,59 @@ def remove_member(room_id, user_id):
|
|||||||
if not is_member:
|
if not is_member:
|
||||||
flash('You do not have access to this room.', 'error')
|
flash('You do not have access to this room.', 'error')
|
||||||
return redirect(url_for('rooms.rooms'))
|
return redirect(url_for('rooms.rooms'))
|
||||||
# Only admins and managers who are members can manage room members
|
if not current_user.is_admin:
|
||||||
if not (current_user.is_admin or (current_user.is_manager and is_member)):
|
flash('Only administrators can manage room members.', 'error')
|
||||||
flash('Only administrators and managers can manage room members.', 'error')
|
|
||||||
return redirect(url_for('rooms.room', room_id=room_id))
|
return redirect(url_for('rooms.room', room_id=room_id))
|
||||||
if user_id == room.created_by:
|
if user_id == room.created_by:
|
||||||
flash('Cannot remove the room creator.', 'error')
|
flash('Cannot remove the room creator.', 'error')
|
||||||
else:
|
else:
|
||||||
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
|
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
|
||||||
if perm:
|
if not perm:
|
||||||
db.session.delete(perm)
|
flash('User is not a member of this room.', 'error')
|
||||||
db.session.commit()
|
|
||||||
flash('Member has been removed from the room.', 'success')
|
|
||||||
else:
|
else:
|
||||||
flash('Member not found.', 'error')
|
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)}")
|
||||||
|
|
||||||
return redirect(url_for('rooms.room_members', room_id=room_id))
|
return redirect(url_for('rooms.room_members', room_id=room_id))
|
||||||
|
|
||||||
@rooms_bp.route('/<int:room_id>/members/<int:user_id>/permissions', methods=['POST'])
|
@rooms_bp.route('/<int:room_id>/members/<int:user_id>/permissions', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def update_member_permissions(room_id, user_id):
|
def update_member_permissions(room_id, user_id):
|
||||||
room = Room.query.get_or_404(room_id)
|
room = Room.query.get_or_404(room_id)
|
||||||
# Check if user is a member
|
if not current_user.is_admin:
|
||||||
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
flash('Only administrators can update permissions.', 'error')
|
||||||
if not (current_user.is_admin or (current_user.is_manager and is_member)):
|
|
||||||
flash('Only administrators and managers can update permissions.', 'error')
|
|
||||||
return redirect(url_for('rooms.room_members', room_id=room_id))
|
return redirect(url_for('rooms.room_members', room_id=room_id))
|
||||||
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
|
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
|
||||||
if not perm:
|
if not perm:
|
||||||
@@ -289,13 +312,11 @@ def update_member_permissions(room_id, user_id):
|
|||||||
@rooms_bp.route('/<int:room_id>/edit', methods=['GET', 'POST'])
|
@rooms_bp.route('/<int:room_id>/edit', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_room(room_id):
|
def edit_room(room_id):
|
||||||
room = Room.query.get_or_404(room_id)
|
if not current_user.is_admin:
|
||||||
# Check if user is a member
|
flash('Only administrators can edit rooms.', 'error')
|
||||||
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
|
||||||
if not (current_user.is_admin or (current_user.is_manager and is_member)):
|
|
||||||
flash('Only administrators and managers can edit rooms.', 'error')
|
|
||||||
return redirect(url_for('rooms.rooms'))
|
return redirect(url_for('rooms.rooms'))
|
||||||
|
|
||||||
|
room = Room.query.get_or_404(room_id)
|
||||||
form = RoomForm()
|
form = RoomForm()
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
@@ -333,20 +354,18 @@ def edit_room(room_id):
|
|||||||
@rooms_bp.route('/<int:room_id>/delete', methods=['POST'])
|
@rooms_bp.route('/<int:room_id>/delete', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_room(room_id):
|
def delete_room(room_id):
|
||||||
room = Room.query.get_or_404(room_id)
|
if not current_user.is_admin:
|
||||||
# Check if user is a member
|
flash('Only administrators can delete rooms.', 'error')
|
||||||
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
|
||||||
if not (current_user.is_admin or (current_user.is_manager and is_member)):
|
|
||||||
flash('Only administrators and managers can delete rooms.', 'error')
|
|
||||||
return redirect(url_for('rooms.rooms'))
|
return redirect(url_for('rooms.rooms'))
|
||||||
|
|
||||||
|
room = Room.query.get_or_404(room_id)
|
||||||
room_name = room.name
|
room_name = room.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"Attempting to delete room {room_id} ({room_name})")
|
print(f"Attempting to delete room {room_id} ({room_name})")
|
||||||
|
|
||||||
# Delete physical files
|
# Delete physical files
|
||||||
room_dir = os.path.join('/app/uploads/rooms', str(room_id))
|
room_dir = os.path.join('/data/rooms', str(room_id))
|
||||||
if os.path.exists(room_dir):
|
if os.path.exists(room_dir):
|
||||||
shutil.rmtree(room_dir)
|
shutil.rmtree(room_dir)
|
||||||
print(f"Deleted room directory: {room_dir}")
|
print(f"Deleted room directory: {room_dir}")
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Utility script to set version environment variables for local development.
|
|
||||||
This replaces the need for version.txt file creation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
def get_git_info():
|
|
||||||
"""Get current git commit hash and branch"""
|
|
||||||
try:
|
|
||||||
# Get current commit hash
|
|
||||||
commit_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'],
|
|
||||||
text=True, stderr=subprocess.DEVNULL).strip()
|
|
||||||
|
|
||||||
# Get current branch
|
|
||||||
branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
||||||
text=True, stderr=subprocess.DEVNULL).strip()
|
|
||||||
|
|
||||||
# Get latest tag
|
|
||||||
try:
|
|
||||||
latest_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'],
|
|
||||||
text=True, stderr=subprocess.DEVNULL).strip()
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
latest_tag = 'unknown'
|
|
||||||
|
|
||||||
return {
|
|
||||||
'commit': commit_hash,
|
|
||||||
'branch': branch,
|
|
||||||
'tag': latest_tag
|
|
||||||
}
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
||||||
return {
|
|
||||||
'commit': 'unknown',
|
|
||||||
'branch': 'unknown',
|
|
||||||
'tag': 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
def set_version_env():
|
|
||||||
"""Set version environment variables"""
|
|
||||||
git_info = get_git_info()
|
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
os.environ['APP_VERSION'] = git_info['tag']
|
|
||||||
os.environ['GIT_COMMIT'] = git_info['commit']
|
|
||||||
os.environ['GIT_BRANCH'] = git_info['branch']
|
|
||||||
os.environ['DEPLOYED_AT'] = datetime.utcnow().isoformat()
|
|
||||||
|
|
||||||
print("Version environment variables set:")
|
|
||||||
print(f"APP_VERSION: {os.environ['APP_VERSION']}")
|
|
||||||
print(f"GIT_COMMIT: {os.environ['GIT_COMMIT']}")
|
|
||||||
print(f"GIT_BRANCH: {os.environ['GIT_BRANCH']}")
|
|
||||||
print(f"DEPLOYED_AT: {os.environ['DEPLOYED_AT']}")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
set_version_env()
|
|
||||||
6
start.sh
6
start.sh
@@ -6,6 +6,12 @@ while ! nc -z db 5432; do
|
|||||||
done
|
done
|
||||||
echo "Database is ready!"
|
echo "Database is ready!"
|
||||||
|
|
||||||
|
echo "Waiting for Redis..."
|
||||||
|
while ! nc -z redis 6379; do
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
echo "Redis is ready!"
|
||||||
|
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
flask db upgrade
|
flask db upgrade
|
||||||
|
|
||||||
|
|||||||
@@ -88,90 +88,10 @@ body {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced Card Styles */
|
|
||||||
.card {
|
.card {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 2px 4px var(--shadow-color);
|
box-shadow: 0 2px 4px var(--shadow-color);
|
||||||
background: var(--white);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover effects only for dashboard and room/conversation cards */
|
|
||||||
body[data-page="dashboard"] .card,
|
|
||||||
body[data-page="rooms"] .card,
|
|
||||||
body[data-page="conversations"] .card {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-page="dashboard"] .card:hover,
|
|
||||||
body[data-page="rooms"] .card:hover,
|
|
||||||
body[data-page="conversations"] .card:hover {
|
|
||||||
box-shadow: 0 8px 16px var(--shadow-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-page="dashboard"] .card::after,
|
|
||||||
body[data-page="rooms"] .card::after,
|
|
||||||
body[data-page="conversations"] .card::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(135deg, var(--primary-opacity-15) 0%, var(--secondary-opacity-15) 100%);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-page="dashboard"] .card:hover::after,
|
|
||||||
body[data-page="rooms"] .card:hover::after,
|
|
||||||
body[data-page="conversations"] .card:hover::after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override hover effects for file grid cards */
|
|
||||||
#fileGrid .card {
|
|
||||||
transform: none !important;
|
|
||||||
box-shadow: 0 2px 4px var(--shadow-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#fileGrid .card:hover {
|
|
||||||
transform: none !important;
|
|
||||||
box-shadow: 0 2px 4px var(--shadow-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#fileGrid .card::after {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar Styles */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: var(--bg-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--primary-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox Scrollbar */
|
|
||||||
* {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--primary-color) var(--bg-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@@ -188,9 +108,6 @@ body[data-page="conversations"] .card:hover::after {
|
|||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-card:hover {
|
||||||
/* Remove hover effect from list group items */
|
transform: translateY(-5px);
|
||||||
.list-group-item-action:hover {
|
|
||||||
background-color: transparent !important;
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
}
|
||||||
@@ -41,14 +41,3 @@
|
|||||||
--border-color: #dee2e6;
|
--border-color: #dee2e6;
|
||||||
--border-light: #e9ecef;
|
--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,48 +1,22 @@
|
|||||||
/* Enhanced Homepage Styles */
|
|
||||||
.navbar {
|
.navbar {
|
||||||
background: var(--white) !important;
|
background-color: var(--primary-color) !important;
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar.scrolled {
|
|
||||||
background: var(--white) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-section {
|
.hero-section {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 120px 0 100px 0;
|
padding: 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 {
|
.feature-card {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 15px;
|
border-radius: 10px;
|
||||||
transition: all 0.3s ease;
|
transition: transform 0.3s;
|
||||||
box-shadow: 0 5px 15px var(--shadow-color);
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
background: var(--white);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card:hover {
|
.feature-card:hover {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-5px);
|
||||||
box-shadow: 0 15px 35px var(--shadow-color-light);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon {
|
.feature-icon {
|
||||||
@@ -51,222 +25,30 @@
|
|||||||
margin-bottom: 1rem;
|
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 {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background-color: var(--primary-color);
|
||||||
border: none;
|
border-color: var(--primary-color);
|
||||||
border-radius: 25px;
|
|
||||||
padding: 12px 30px;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: linear-gradient(135deg, var(--primary-light) 0%, var(--secondary-light) 100%);
|
background-color: var(--primary-light);
|
||||||
transform: translateY(-2px);
|
border-color: var(--primary-light);
|
||||||
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline-primary {
|
.btn-outline-primary {
|
||||||
border: 2px solid var(--primary-color);
|
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
border-radius: 25px;
|
border-color: var(--primary-color);
|
||||||
padding: 12px 30px;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline-primary:hover {
|
.btn-outline-primary:hover {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background-color: var(--primary-color);
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-light {
|
footer {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background-color: var(--primary-color) !important;
|
||||||
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 {
|
.nav-link:hover {
|
||||||
color: rgba(255, 255, 255, 0.8) !important;
|
color: var(--secondary-color) !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);
|
|
||||||
}
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
.launch-steps-container {
|
|
||||||
max-height: calc(100vh - 600px);
|
|
||||||
min-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.launch-steps-container::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.launch-steps-container::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.launch-steps-container::-webkit-scrollbar-thumb {
|
|
||||||
background: #888;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.launch-steps-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.active {
|
|
||||||
background-color: #e3f2fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.completed {
|
|
||||||
background-color: #e8f5e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.failed {
|
|
||||||
background-color: #ffebee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.warning {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #e9ecef;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.active .step-icon {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.completed .step-icon {
|
|
||||||
background-color: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.failed .step-icon {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.warning .step-icon {
|
|
||||||
background-color: #ffc107;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-content {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-content h5 {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-status {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.completed .step-status {
|
|
||||||
color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.failed .step-status {
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item.warning .step-status {
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/* Connection Cards */
|
|
||||||
.card {
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.125);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
background-color: #fff;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Elements */
|
|
||||||
.form-label {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
box-shadow: 0 0 0 0.2rem rgba(var(--primary-color-rgb), 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-text {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6c757d;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn {
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--primary-dark);
|
|
||||||
border-color: var(--primary-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-primary {
|
|
||||||
color: var(--primary-color);
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-primary:hover {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-content {
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
border-top: 1px solid #dee2e6;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input Groups */
|
|
||||||
.input-group {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: stretch;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group .form-control {
|
|
||||||
position: relative;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
width: 1%;
|
|
||||||
min-width: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group .btn {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icons */
|
|
||||||
.fas {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group .btn {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/* Summernote custom styles */
|
|
||||||
.note-editor {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.note-editor.note-frame {
|
|
||||||
border-color: #dee2e6;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
}
|
|
||||||
.note-editor.note-frame:focus-within {
|
|
||||||
border-color: #86b7fe;
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
|
||||||
}
|
|
||||||
.note-toolbar {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-top-left-radius: 0.375rem;
|
|
||||||
border-top-right-radius: 0.375rem;
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
.note-editing-area {
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
.note-statusbar {
|
|
||||||
border-top: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
.note-placeholder {
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Variable card styles */
|
|
||||||
#variableList .card {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
#variableList .card:hover {
|
|
||||||
border-color: #86b7fe;
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.1);
|
|
||||||
}
|
|
||||||
#variableList code {
|
|
||||||
font-size: 0.9em;
|
|
||||||
padding: 0.2em 0.4em;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/* Log filters form */
|
|
||||||
#logFiltersForm .form-label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logFiltersForm .form-control,
|
|
||||||
#logFiltersForm .form-select {
|
|
||||||
border-color: #dee2e6;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logFiltersForm .form-control:focus,
|
|
||||||
#logFiltersForm .form-select:focus {
|
|
||||||
border-color: #86b7fe;
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Logs table */
|
|
||||||
.table {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Log level badges */
|
|
||||||
.badge {
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0.35em 0.65em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.bg-info {
|
|
||||||
background-color: #0dcaf0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.bg-warning {
|
|
||||||
background-color: #ffc107 !important;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.bg-danger {
|
|
||||||
background-color: #dc3545 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.bg-secondary {
|
|
||||||
background-color: #6c757d !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Log details modal */
|
|
||||||
#logDetailsContent {
|
|
||||||
max-height: 70vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logDetailsContent .bg-light {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination */
|
|
||||||
.pagination {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-link {
|
|
||||||
color: #0d6efd;
|
|
||||||
border-color: #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-link:hover {
|
|
||||||
color: #0a58ca;
|
|
||||||
background-color: #e9ecef;
|
|
||||||
border-color: #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item.active .page-link {
|
|
||||||
background-color: #0d6efd;
|
|
||||||
border-color: #0d6efd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item.disabled .page-link {
|
|
||||||
color: #6c757d;
|
|
||||||
pointer-events: none;
|
|
||||||
background-color: #fff;
|
|
||||||
border-color: #dee2e6;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user