first
This commit is contained in:
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first to leverage Docker cache
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=app.py
|
||||
ENV FLASK_ENV=development
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 5000
|
||||
|
||||
# Command to run the application
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
|
||||
BIN
__pycache__/app.cpython-313.pyc
Normal file
BIN
__pycache__/app.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/forms.cpython-313.pyc
Normal file
BIN
__pycache__/forms.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/models.cpython-313.pyc
Normal file
BIN
__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/routes.cpython-313.pyc
Normal file
BIN
__pycache__/routes.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/tasks.cpython-313.pyc
Normal file
BIN
__pycache__/tasks.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/utils.cpython-313.pyc
Normal file
BIN
__pycache__/utils.cpython-313.pyc
Normal file
Binary file not shown.
70
app.py
Normal file
70
app.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from flask import Flask, send_from_directory
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
from models import db, User
|
||||
from flask_wtf.csrf import CSRFProtect, generate_csrf
|
||||
from routes.room_files import room_files_bp
|
||||
from routes.user import user_bp
|
||||
from routes.room_members import room_members_bp
|
||||
from routes.trash import trash_bp
|
||||
from tasks import cleanup_trash
|
||||
import click
|
||||
from utils import timeago
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
# Configure the database
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://postgres:1253@localhost:5432/docupulse'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
||||
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads')
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
migrate = Migrate(app, db)
|
||||
login_manager = LoginManager(app)
|
||||
login_manager.login_view = 'auth.login'
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
@app.context_processor
|
||||
def inject_csrf_token():
|
||||
return dict(csrf_token=generate_csrf())
|
||||
|
||||
# User loader for Flask-Login
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# Initialize routes
|
||||
from routes import init_app
|
||||
init_app(app)
|
||||
app.register_blueprint(room_files_bp, url_prefix='/api/rooms')
|
||||
app.register_blueprint(room_members_bp, url_prefix='/api/rooms')
|
||||
app.register_blueprint(trash_bp, url_prefix='/api/rooms')
|
||||
app.register_blueprint(user_bp)
|
||||
|
||||
@app.cli.command("cleanup-trash")
|
||||
def cleanup_trash_command():
|
||||
"""Clean up files that have been in trash for more than 30 days."""
|
||||
cleanup_trash()
|
||||
click.echo("Trash cleanup completed.")
|
||||
|
||||
# Register custom filters
|
||||
app.jinja_env.filters['timeago'] = timeago
|
||||
|
||||
return app
|
||||
|
||||
app = create_app()
|
||||
|
||||
@app.route('/uploads/profile_pics/<filename>')
|
||||
def profile_pic(filename):
|
||||
return send_from_directory(os.path.join(os.getcwd(), 'uploads', 'profile_pics'), filename)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
59
clear_files_and_db.py
Normal file
59
clear_files_and_db.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
import shutil
|
||||
from app import create_app
|
||||
from models import db, RoomFile, Room, RoomMemberPermission
|
||||
from sqlalchemy import text
|
||||
|
||||
app = create_app()
|
||||
|
||||
def clear_all_data():
|
||||
with app.app_context():
|
||||
# Delete records in the correct order to handle foreign key constraints
|
||||
# 1. Delete all RoomFile records from the database
|
||||
RoomFile.query.delete()
|
||||
print("All RoomFile records deleted.")
|
||||
|
||||
# 2. Delete all RoomMemberPermission records
|
||||
RoomMemberPermission.query.delete()
|
||||
print("All RoomMemberPermission records deleted.")
|
||||
|
||||
# 3. Delete all room_members associations
|
||||
db.session.execute(text('DELETE FROM room_members'))
|
||||
print("All room_members associations deleted.")
|
||||
|
||||
# 4. Delete all Room records
|
||||
Room.query.delete()
|
||||
print("All Room records deleted.")
|
||||
|
||||
# Commit the database changes
|
||||
db.session.commit()
|
||||
print("Database cleanup completed.")
|
||||
|
||||
def clear_filesystem():
|
||||
# 1. Clear the data/rooms directory
|
||||
data_root = os.path.join(os.path.dirname(__file__), 'data', 'rooms')
|
||||
if os.path.exists(data_root):
|
||||
for item in os.listdir(data_root):
|
||||
item_path = os.path.join(data_root, item)
|
||||
if os.path.isfile(item_path):
|
||||
os.remove(item_path)
|
||||
elif os.path.isdir(item_path):
|
||||
shutil.rmtree(item_path)
|
||||
print("Cleared data/rooms directory")
|
||||
|
||||
# 2. Clear the uploads directory except for profile_pics
|
||||
uploads_dir = os.path.join(os.path.dirname(__file__), 'uploads')
|
||||
if os.path.exists(uploads_dir):
|
||||
for item in os.listdir(uploads_dir):
|
||||
if item != 'profile_pics':
|
||||
item_path = os.path.join(uploads_dir, item)
|
||||
if os.path.isfile(item_path):
|
||||
os.remove(item_path)
|
||||
elif os.path.isdir(item_path):
|
||||
shutil.rmtree(item_path)
|
||||
print("Cleared uploads directory")
|
||||
|
||||
if __name__ == '__main__':
|
||||
clear_all_data()
|
||||
clear_filesystem()
|
||||
print("Cleanup completed successfully!")
|
||||
27
clear_specific_files.py
Normal file
27
clear_specific_files.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from app import create_app, db
|
||||
from app.models import RoomFile, Room
|
||||
import os
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
# Get the Test room
|
||||
room = Room.query.filter_by(name='Test').first()
|
||||
if not room:
|
||||
print("Test room not found")
|
||||
exit(1)
|
||||
|
||||
# Delete from database
|
||||
files = ['Screenshot_2025-03-19_100338.png', 'Screenshot_2025-03-19_100419.png']
|
||||
deleted = RoomFile.query.filter_by(room_id=room.id, name__in=files).delete()
|
||||
db.session.commit()
|
||||
print(f"Deleted {deleted} records from database")
|
||||
|
||||
# Delete from filesystem
|
||||
room_path = os.path.join('data', 'rooms', str(room.id))
|
||||
for file in files:
|
||||
file_path = os.path.join(room_path, file)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
print(f"Deleted file: {file_path}")
|
||||
else:
|
||||
print(f"File not found: {file_path}")
|
||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "10334:5000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/flask_app
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=development
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- .:/app
|
||||
- room_data:/data
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=flask_app
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
room_data:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
50
forms.py
Normal file
50
forms.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField
|
||||
from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError
|
||||
from models import User
|
||||
from flask_login import current_user
|
||||
from flask_wtf.file import FileField, FileAllowed
|
||||
|
||||
class UserForm(FlaskForm):
|
||||
first_name = StringField('First Name', validators=[DataRequired(), Length(min=2, max=100)])
|
||||
last_name = StringField('Last Name', validators=[DataRequired(), Length(min=2, max=100)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=150)])
|
||||
phone = StringField('Phone (Optional)', validators=[Optional(), Length(max=20)])
|
||||
company = StringField('Company (Optional)', validators=[Optional(), Length(max=100)])
|
||||
position = StringField('Position (Optional)', validators=[Optional(), Length(max=100)])
|
||||
notes = TextAreaField('Notes (Optional)', validators=[Optional()])
|
||||
is_active = BooleanField('Active', default=True)
|
||||
is_admin = BooleanField('Admin Role', default=False)
|
||||
new_password = PasswordField('New Password (Optional)')
|
||||
confirm_password = PasswordField('Confirm Password (Optional)')
|
||||
profile_picture = FileField('Profile Picture (Optional)', validators=[FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!')])
|
||||
submit = SubmitField('Save Contact')
|
||||
|
||||
def validate_is_admin(self, field):
|
||||
# Only validate when trying to uncheck admin status
|
||||
if not field.data:
|
||||
# Get the user being edited (either current user or a new user)
|
||||
user = User.query.filter_by(email=self.email.data).first()
|
||||
# If this is an existing admin user and they're the only admin
|
||||
if user and user.is_admin:
|
||||
total_admins = User.query.filter_by(is_admin=True).count()
|
||||
if total_admins <= 1:
|
||||
raise ValidationError('There must be at least one admin user in the system.')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
rv = super().validate(extra_validators=extra_validators)
|
||||
if not rv:
|
||||
return False
|
||||
if self.new_password.data or self.confirm_password.data:
|
||||
if not self.new_password.data or not self.confirm_password.data:
|
||||
self.confirm_password.errors.append('Both password fields are required.')
|
||||
return False
|
||||
if self.new_password.data != self.confirm_password.data:
|
||||
self.confirm_password.errors.append('Passwords must match.')
|
||||
return False
|
||||
return True
|
||||
|
||||
class RoomForm(FlaskForm):
|
||||
name = StringField('Room Name', validators=[DataRequired(), Length(min=3, max=100)])
|
||||
description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
|
||||
submit = SubmitField('Create Room')
|
||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
BIN
migrations/__pycache__/env.cpython-313.pyc
Normal file
BIN
migrations/__pycache__/env.cpython-313.pyc
Normal file
Binary file not shown.
46
migrations/alembic.ini
Normal file
46
migrations/alembic.ini
Normal file
@@ -0,0 +1,46 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
|
||||
# A generic, single database configuration.
|
||||
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add user authentication fields
|
||||
|
||||
Revision ID: 1c297825e3a9
|
||||
Revises:
|
||||
Create Date: 2025-05-23 08:39:40.494853
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1c297825e3a9'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('user',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=150), nullable=False),
|
||||
sa.Column('email', sa.String(length=150), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=128), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email'),
|
||||
sa.UniqueConstraint('username')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('user')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add is_admin to Contact model
|
||||
|
||||
Revision ID: 25da158dd705
|
||||
Revises: 7a5747dc773f
|
||||
Create Date: 2025-05-23 16:10:53.731035
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '25da158dd705'
|
||||
down_revision = '7a5747dc773f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('contact', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('contact', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_admin')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add room member permissions table
|
||||
|
||||
Revision ID: 26b0e5357f52
|
||||
Revises: 2c5f57dddb78
|
||||
Create Date: 2025-05-23 21:44:58.832286
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '26b0e5357f52'
|
||||
down_revision = '2c5f57dddb78'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('room_member_permissions',
|
||||
sa.Column('room_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('can_view', sa.Boolean(), nullable=False),
|
||||
sa.Column('can_upload', sa.Boolean(), nullable=False),
|
||||
sa.Column('can_delete', sa.Boolean(), nullable=False),
|
||||
sa.Column('can_share', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['room.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('room_id', 'user_id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('room_member_permissions')
|
||||
# ### end Alembic commands ###
|
||||
40
migrations/versions/2c5f57dddb78_add_room_members_table.py
Normal file
40
migrations/versions/2c5f57dddb78_add_room_members_table.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Add room members table
|
||||
|
||||
Revision ID: 2c5f57dddb78
|
||||
Revises: 3a5b8d8e53cd
|
||||
Create Date: 2025-05-23 21:27:17.497481
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2c5f57dddb78'
|
||||
down_revision = '3a5b8d8e53cd'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('room_members',
|
||||
sa.Column('room_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['room.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('room_id', 'user_id')
|
||||
)
|
||||
with op.batch_alter_table('room', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_private')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('room', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_private', sa.BOOLEAN(), autoincrement=False, nullable=True))
|
||||
|
||||
op.drop_table('room_members')
|
||||
# ### end Alembic commands ###
|
||||
37
migrations/versions/3a5b8d8e53cd_add_rooms_table.py
Normal file
37
migrations/versions/3a5b8d8e53cd_add_rooms_table.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Add rooms table
|
||||
|
||||
Revision ID: 3a5b8d8e53cd
|
||||
Revises: c21f243b3640
|
||||
Create Date: 2025-05-23 21:25:27.880150
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3a5b8d8e53cd'
|
||||
down_revision = 'c21f243b3640'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('room',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||
sa.Column('is_private', sa.Boolean(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('room')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add contact fields to User model
|
||||
|
||||
Revision ID: 43dfd2543fad
|
||||
Revises: dbcb5d2d3ed0
|
||||
Create Date: 2025-05-23 09:24:23.926302
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '43dfd2543fad'
|
||||
down_revision = 'dbcb5d2d3ed0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('phone', sa.String(length=20), nullable=True))
|
||||
batch_op.add_column(sa.Column('company', sa.String(length=100), nullable=True))
|
||||
batch_op.add_column(sa.Column('position', sa.String(length=100), nullable=True))
|
||||
batch_op.add_column(sa.Column('notes', sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=True))
|
||||
|
||||
# ### 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_active')
|
||||
batch_op.drop_column('notes')
|
||||
batch_op.drop_column('position')
|
||||
batch_op.drop_column('company')
|
||||
batch_op.drop_column('phone')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,47 @@
|
||||
"""add room_file table for file/folder metadata and uploader info
|
||||
|
||||
Revision ID: 64b5c28510b0
|
||||
Revises: b978642e7b10
|
||||
Create Date: 2025-05-24 10:07:02.159730
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '64b5c28510b0'
|
||||
down_revision = 'b978642e7b10'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('room_file',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('room_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('path', sa.String(length=1024), nullable=False),
|
||||
sa.Column('type', sa.String(length=10), nullable=False),
|
||||
sa.Column('size', sa.BigInteger(), nullable=True),
|
||||
sa.Column('modified', sa.Float(), nullable=True),
|
||||
sa.Column('uploaded_by', sa.Integer(), nullable=False),
|
||||
sa.Column('uploaded_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['room.id'], ),
|
||||
sa.ForeignKeyConstraint(['uploaded_by'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('room_member_permissions', schema=None) as batch_op:
|
||||
batch_op.drop_column('preferred_view')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('room_member_permissions', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('preferred_view', sa.VARCHAR(length=10), autoincrement=False, nullable=False))
|
||||
|
||||
op.drop_table('room_file')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Add starred column to room_file table
|
||||
|
||||
Revision ID: 6651332488d9
|
||||
Revises: be1f7bdd10e1
|
||||
Create Date: 2025-05-24 18:14:38.320999
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6651332488d9'
|
||||
down_revision = 'be1f7bdd10e1'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('starred', sa.Boolean(), nullable=True))
|
||||
batch_op.alter_column('path',
|
||||
existing_type=sa.VARCHAR(length=1024),
|
||||
type_=sa.String(length=255),
|
||||
existing_nullable=False)
|
||||
batch_op.alter_column('size',
|
||||
existing_type=sa.BIGINT(),
|
||||
type_=sa.Integer(),
|
||||
existing_nullable=True)
|
||||
batch_op.alter_column('uploaded_by',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
batch_op.alter_column('uploaded_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
nullable=True)
|
||||
|
||||
# ### 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.alter_column('uploaded_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
nullable=False)
|
||||
batch_op.alter_column('uploaded_by',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
batch_op.alter_column('size',
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.BIGINT(),
|
||||
existing_nullable=True)
|
||||
batch_op.alter_column('path',
|
||||
existing_type=sa.String(length=255),
|
||||
type_=sa.VARCHAR(length=1024),
|
||||
existing_nullable=False)
|
||||
batch_op.drop_column('starred')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
34
migrations/versions/7554ab70efe7_fix_existing_users.py
Normal file
34
migrations/versions/7554ab70efe7_fix_existing_users.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""fix existing users
|
||||
|
||||
Revision ID: 7554ab70efe7
|
||||
Revises: 43dfd2543fad
|
||||
Create Date: 2024-03-19 10:05:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7554ab70efe7'
|
||||
down_revision = '43dfd2543fad'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Update existing users to set default values
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE "user"
|
||||
SET position = 'Administrator',
|
||||
is_active = true
|
||||
WHERE position IS NULL
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
24
migrations/versions/76da0573e84b_merge_heads.py
Normal file
24
migrations/versions/76da0573e84b_merge_heads.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""merge heads
|
||||
|
||||
Revision ID: 76da0573e84b
|
||||
Revises: add_deleted_by_to_room_file, create_user_starred_file_table
|
||||
Create Date: 2025-05-25 10:03:03.423064
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '76da0573e84b'
|
||||
down_revision = ('add_deleted_by_to_room_file', 'create_user_starred_file_table')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Update last_name field in User model
|
||||
|
||||
Revision ID: 7a5747dc773f
|
||||
Revises: c243d6a1843d
|
||||
Create Date: 2024-03-19 10:15:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7a5747dc773f'
|
||||
down_revision = 'c243d6a1843d'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# First, update any null last_name values to '(You)'
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE "user"
|
||||
SET last_name = '(You)'
|
||||
WHERE last_name IS NULL
|
||||
""")
|
||||
)
|
||||
|
||||
# Then make the column non-nullable
|
||||
op.alter_column('user', 'last_name',
|
||||
existing_type=sa.String(length=150),
|
||||
nullable=False,
|
||||
server_default='(You)')
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.alter_column('user', 'last_name',
|
||||
existing_type=sa.String(length=150),
|
||||
nullable=True,
|
||||
server_default=None)
|
||||
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.
33
migrations/versions/add_deleted_by_to_room_file.py
Normal file
33
migrations/versions/add_deleted_by_to_room_file.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Add deleted_by column to room_file table
|
||||
|
||||
Revision ID: add_deleted_by_to_room_file
|
||||
Revises: add_deleted_column_to_room_file
|
||||
Create Date: 2024-03-19 10:45:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_deleted_by_to_room_file'
|
||||
down_revision = 'add_deleted_column_to_room_file'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('deleted_by', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_room_file_deleted_by_user', 'user', ['deleted_by'], ['id'])
|
||||
|
||||
# ### 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.drop_constraint('fk_room_file_deleted_by_user', type_='foreignkey')
|
||||
batch_op.drop_column('deleted_at')
|
||||
batch_op.drop_column('deleted_by')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
29
migrations/versions/add_deleted_column_to_room_file.py
Normal file
29
migrations/versions/add_deleted_column_to_room_file.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Add deleted column to room_file table
|
||||
|
||||
Revision ID: add_deleted_column_to_room_file
|
||||
Revises: add_trashed_file_table
|
||||
Create Date: 2024-03-19 10:30:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_deleted_column_to_room_file'
|
||||
down_revision = 'add_trashed_file_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('deleted', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# ### 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.drop_column('deleted')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
42
migrations/versions/add_trashed_file_table.py
Normal file
42
migrations/versions/add_trashed_file_table.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Add trashed file table
|
||||
|
||||
Revision ID: add_trashed_file_table
|
||||
Revises: 6651332488d9
|
||||
Create Date: 2024-03-19 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_trashed_file_table'
|
||||
down_revision = '6651332488d9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('trashed_file',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('room_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('original_path', sa.String(length=255), nullable=False),
|
||||
sa.Column('type', sa.String(length=10), nullable=False),
|
||||
sa.Column('size', sa.Integer(), nullable=True),
|
||||
sa.Column('modified', sa.Float(), nullable=True),
|
||||
sa.Column('uploaded_by', sa.Integer(), nullable=True),
|
||||
sa.Column('uploaded_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('deleted_by', sa.Integer(), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['deleted_by'], ['user.id'], ),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['room.id'], ),
|
||||
sa.ForeignKeyConstraint(['uploaded_by'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('trashed_file')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add preferred_view to user
|
||||
|
||||
Revision ID: b978642e7b10
|
||||
Revises: 26b0e5357f52
|
||||
Create Date: 2025-05-24 08:36:03.426879
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b978642e7b10'
|
||||
down_revision = '26b0e5357f52'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add preferred_view as nullable first
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('preferred_view', sa.String(length=10), nullable=True))
|
||||
# Set default value for existing users
|
||||
op.execute("UPDATE \"user\" SET preferred_view = 'grid'")
|
||||
# Make the column non-nullable
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.alter_column('preferred_view', nullable=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_column('preferred_view')
|
||||
|
||||
with op.batch_alter_table('room_member_permissions', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('preferred_view', sa.VARCHAR(length=10), autoincrement=False, nullable=False))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add granular permissions to RoomMemberPermission
|
||||
|
||||
Revision ID: be1f7bdd10e1
|
||||
Revises: 64b5c28510b0
|
||||
Create Date: 2025-05-24 12:32:19.239241
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'be1f7bdd10e1'
|
||||
down_revision = '64b5c28510b0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('room_member_permissions', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('can_download', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch_op.add_column(sa.Column('can_rename', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch_op.add_column(sa.Column('can_move', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('room_member_permissions', schema=None) as batch_op:
|
||||
batch_op.drop_column('can_move')
|
||||
batch_op.drop_column('can_rename')
|
||||
batch_op.drop_column('can_download')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,51 @@
|
||||
"""add profile_picture to user
|
||||
|
||||
Revision ID: c21f243b3640
|
||||
Revises: 25da158dd705
|
||||
Create Date: 2025-05-23 19:28:16.977187
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c21f243b3640'
|
||||
down_revision = '25da158dd705'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('contact')
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('profile_picture', sa.String(length=255), nullable=True))
|
||||
|
||||
# ### 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('profile_picture')
|
||||
|
||||
op.create_table('contact',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('first_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('last_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('email', sa.VARCHAR(length=150), autoincrement=False, nullable=False),
|
||||
sa.Column('phone', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||
sa.Column('company', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('position', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('notes', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('owner_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.Column('is_admin', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], name=op.f('contact_owner_id_fkey')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('contact_pkey')),
|
||||
sa.UniqueConstraint('email', name=op.f('contact_email_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add last_name to User model
|
||||
|
||||
Revision ID: c243d6a1843d
|
||||
Revises: 7554ab70efe7
|
||||
Create Date: 2025-05-23 16:00:09.905001
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c243d6a1843d'
|
||||
down_revision = '7554ab70efe7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('last_name', sa.String(length=150), nullable=True))
|
||||
|
||||
# ### 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('last_name')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
41
migrations/versions/create_user_starred_file_table.py
Normal file
41
migrations/versions/create_user_starred_file_table.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Create user_starred_file table and remove starred column
|
||||
|
||||
Revision ID: create_user_starred_file_table
|
||||
Revises: 6651332488d9
|
||||
Create Date: 2024-03-19 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'create_user_starred_file_table'
|
||||
down_revision = '6651332488d9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# Create user_starred_file table
|
||||
op.create_table('user_starred_file',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('file_id', sa.Integer(), nullable=False),
|
||||
sa.Column('starred_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['room_file.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'file_id', name='unique_user_file_star')
|
||||
)
|
||||
|
||||
# Remove starred column from room_file
|
||||
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
||||
batch_op.drop_column('starred')
|
||||
|
||||
def downgrade():
|
||||
# Add starred column back to room_file
|
||||
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('starred', sa.Boolean(), nullable=True))
|
||||
|
||||
# Drop user_starred_file table
|
||||
op.drop_table('user_starred_file')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Increase password_hash column length
|
||||
|
||||
Revision ID: d8dcbf9fe881
|
||||
Revises: 1c297825e3a9
|
||||
Create Date: 2025-05-23 08:45:00.693155
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd8dcbf9fe881'
|
||||
down_revision = '1c297825e3a9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.alter_column('password_hash',
|
||||
existing_type=sa.VARCHAR(length=128),
|
||||
type_=sa.String(length=256),
|
||||
existing_nullable=True)
|
||||
|
||||
# ### 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.alter_column('password_hash',
|
||||
existing_type=sa.String(length=256),
|
||||
type_=sa.VARCHAR(length=128),
|
||||
existing_nullable=True)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
52
migrations/versions/dbcb5d2d3ed0_add_contact_model.py
Normal file
52
migrations/versions/dbcb5d2d3ed0_add_contact_model.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Add Contact model
|
||||
|
||||
Revision ID: dbcb5d2d3ed0
|
||||
Revises: d8dcbf9fe881
|
||||
Create Date: 2025-05-23 08:55:10.537722
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'dbcb5d2d3ed0'
|
||||
down_revision = 'd8dcbf9fe881'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('contact',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('first_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('last_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('email', sa.String(length=150), nullable=False),
|
||||
sa.Column('phone', sa.String(length=20), nullable=True),
|
||||
sa.Column('company', sa.String(length=100), nullable=True),
|
||||
sa.Column('position', sa.String(length=100), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email')
|
||||
)
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=True))
|
||||
batch_op.add_column(sa.Column('created_at', sa.DateTime(), nullable=True))
|
||||
|
||||
# ### 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('created_at')
|
||||
batch_op.drop_column('is_admin')
|
||||
|
||||
op.drop_table('contact')
|
||||
# ### end Alembic commands ###
|
||||
127
models.py
Normal file
127
models.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
# Association table for room members
|
||||
room_members = db.Table('room_members',
|
||||
db.Column('room_id', db.Integer, db.ForeignKey('room.id'), primary_key=True),
|
||||
db.Column('user_id', db.Integer, db.ForeignKey('user.id'), primary_key=True)
|
||||
)
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||
last_name = db.Column(db.String(150), nullable=False, default='(You)')
|
||||
email = db.Column(db.String(150), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(256))
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
phone = db.Column(db.String(20))
|
||||
company = db.Column(db.String(100))
|
||||
position = db.Column(db.String(100))
|
||||
notes = db.Column(db.Text)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
profile_picture = db.Column(db.String(255))
|
||||
preferred_view = db.Column(db.String(10), default='grid', nullable=False) # 'grid' or 'list'
|
||||
room_permissions = relationship('RoomMemberPermission', back_populates='user')
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
class Room(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
creator = db.relationship('User', backref='created_rooms', foreign_keys=[created_by])
|
||||
members = db.relationship('User', secondary=room_members, backref=db.backref('rooms', lazy='dynamic'))
|
||||
member_permissions = relationship('RoomMemberPermission', back_populates='room', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Room {self.name}>'
|
||||
|
||||
# Association table for room members with permissions
|
||||
class RoomMemberPermission(db.Model):
|
||||
__tablename__ = 'room_member_permissions'
|
||||
room_id = db.Column(db.Integer, db.ForeignKey('room.id'), primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
|
||||
can_view = db.Column(db.Boolean, default=True, nullable=False)
|
||||
can_download = db.Column(db.Boolean, default=False, nullable=False)
|
||||
can_upload = db.Column(db.Boolean, default=False, nullable=False)
|
||||
can_delete = db.Column(db.Boolean, default=False, nullable=False)
|
||||
can_rename = db.Column(db.Boolean, default=False, nullable=False)
|
||||
can_move = db.Column(db.Boolean, default=False, nullable=False)
|
||||
can_share = db.Column(db.Boolean, default=False, nullable=False)
|
||||
# Relationships
|
||||
user = relationship('User', back_populates='room_permissions')
|
||||
room = relationship('Room', back_populates='member_permissions')
|
||||
|
||||
class RoomFile(db.Model):
|
||||
__tablename__ = 'room_file'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
room_id = db.Column(db.Integer, db.ForeignKey('room.id'), nullable=False)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
path = db.Column(db.String(255), nullable=False, default='')
|
||||
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
|
||||
size = db.Column(db.Integer) # in bytes, null for folders
|
||||
modified = db.Column(db.Float) # timestamp
|
||||
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
deleted = db.Column(db.Boolean, default=False) # New field for deleted status
|
||||
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id')) # New field for tracking who deleted the file
|
||||
deleted_at = db.Column(db.DateTime) # New field for tracking when the file was deleted
|
||||
uploader = db.relationship('User', backref='uploaded_files', foreign_keys=[uploaded_by])
|
||||
deleter = db.relationship('User', backref='deleted_room_files', foreign_keys=[deleted_by])
|
||||
room = db.relationship('Room', backref='files')
|
||||
starred_by = db.relationship('User', secondary='user_starred_file', backref='starred_files')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<RoomFile {self.name} ({self.type}) in {self.path}>'
|
||||
|
||||
class UserStarredFile(db.Model):
|
||||
__tablename__ = 'user_starred_file'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
file_id = db.Column(db.Integer, db.ForeignKey('room_file.id'), nullable=False)
|
||||
starred_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Add unique constraint to prevent duplicate stars
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'file_id', name='unique_user_file_star'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<UserStarredFile user_id={self.user_id} file_id={self.file_id}>'
|
||||
|
||||
class TrashedFile(db.Model):
|
||||
__tablename__ = 'trashed_file'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
room_id = db.Column(db.Integer, db.ForeignKey('room.id'), nullable=False)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
original_path = db.Column(db.String(255), nullable=False, default='')
|
||||
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
|
||||
size = db.Column(db.Integer) # in bytes, null for folders
|
||||
modified = db.Column(db.Float) # timestamp
|
||||
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
deleted_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
room = db.relationship('Room', backref='trashed_files')
|
||||
uploader = db.relationship('User', foreign_keys=[uploaded_by], backref='uploaded_trashed_files')
|
||||
deleter = db.relationship('User', foreign_keys=[deleted_by], backref='deleted_trashed_files') # Changed from deleted_files to deleted_trashed_files
|
||||
|
||||
def __repr__(self):
|
||||
return f'<TrashedFile {self.name} ({self.type}) from {self.original_path}>'
|
||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Flask==3.0.2
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Login==0.6.2
|
||||
Flask-WTF==1.2.1
|
||||
Flask-Migrate==4.0.5
|
||||
SQLAlchemy==2.0.23
|
||||
Werkzeug==3.0.1
|
||||
WTForms==3.1.1
|
||||
python-dotenv==1.0.1
|
||||
psycopg2-binary==2.9.9
|
||||
gunicorn==21.2.0
|
||||
33
routes/__init__.py
Normal file
33
routes/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from flask import Blueprint, Flask, render_template
|
||||
from flask_login import login_required
|
||||
|
||||
def init_app(app: Flask):
|
||||
# Create blueprints
|
||||
main_bp = Blueprint('main', __name__)
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
rooms_bp = Blueprint('rooms', __name__)
|
||||
|
||||
# Import and initialize routes
|
||||
from .main import init_routes as init_main_routes
|
||||
from .auth import init_routes as init_auth_routes
|
||||
from .contacts import contacts_bp as contacts_routes
|
||||
from .rooms import rooms_bp as rooms_routes
|
||||
|
||||
# Initialize routes
|
||||
init_main_routes(main_bp)
|
||||
init_auth_routes(auth_bp)
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(rooms_routes)
|
||||
app.register_blueprint(contacts_routes)
|
||||
|
||||
@app.route('/rooms/<int:room_id>/trash')
|
||||
@login_required
|
||||
def trash_page(room_id):
|
||||
return render_template('trash.html')
|
||||
|
||||
return app
|
||||
|
||||
# This file makes the routes directory a Python package
|
||||
BIN
routes/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
routes/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
routes/__pycache__/auth.cpython-313.pyc
Normal file
BIN
routes/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
routes/__pycache__/contacts.cpython-313.pyc
Normal file
BIN
routes/__pycache__/contacts.cpython-313.pyc
Normal file
Binary file not shown.
BIN
routes/__pycache__/main.cpython-313.pyc
Normal file
BIN
routes/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
routes/__pycache__/room_files.cpython-313.pyc
Normal file
BIN
routes/__pycache__/room_files.cpython-313.pyc
Normal file
Binary file not shown.
BIN
routes/__pycache__/room_members.cpython-313.pyc
Normal file
BIN
routes/__pycache__/room_members.cpython-313.pyc
Normal file
Binary file not shown.
BIN
routes/__pycache__/rooms.cpython-313.pyc
Normal file
BIN
routes/__pycache__/rooms.cpython-313.pyc
Normal file
Binary file not shown.
BIN
routes/__pycache__/trash.cpython-313.pyc
Normal file
BIN
routes/__pycache__/trash.cpython-313.pyc
Normal file
Binary file not shown.
BIN
routes/__pycache__/user.cpython-313.pyc
Normal file
BIN
routes/__pycache__/user.cpython-313.pyc
Normal file
Binary file not shown.
62
routes/auth.py
Normal file
62
routes/auth.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from flask import render_template, request, flash, redirect, url_for
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from models import db, User
|
||||
|
||||
def init_routes(auth_bp):
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
email = request.form.get('email')
|
||||
password = request.form.get('password')
|
||||
remember = True if request.form.get('remember') else False
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
|
||||
if not user or not user.check_password(password):
|
||||
flash('Please check your login details and try again.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
login_user(user, remember=remember)
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
email = request.form.get('email')
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if user:
|
||||
flash('Email address already exists', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user:
|
||||
flash('Username already exists', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
new_user = User(email=email, username=username)
|
||||
new_user.set_password(password)
|
||||
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
flash('Registration successful! Please login.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('register.html')
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('main.home'))
|
||||
264
routes/contacts.py
Normal file
264
routes/contacts.py
Normal file
@@ -0,0 +1,264 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, User
|
||||
from forms import UserForm
|
||||
from flask import abort
|
||||
from sqlalchemy import or_
|
||||
import json
|
||||
import os
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
contacts_bp = Blueprint('contacts', __name__, url_prefix='/contacts')
|
||||
|
||||
UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads', 'profile_pics')
|
||||
if not os.path.exists(UPLOAD_FOLDER):
|
||||
os.makedirs(UPLOAD_FOLDER)
|
||||
|
||||
def admin_required():
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login'))
|
||||
if not current_user.is_admin:
|
||||
flash('You must be an admin to access this page.', 'error')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
@contacts_bp.route('/')
|
||||
@login_required
|
||||
def contacts_list():
|
||||
result = admin_required()
|
||||
if result: return result
|
||||
|
||||
# Get query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 10 # Number of items per page
|
||||
search = request.args.get('search', '')
|
||||
status = request.args.get('status', '')
|
||||
role = request.args.get('role', '')
|
||||
|
||||
# Start with base query
|
||||
query = User.query
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.username.ilike(search_term),
|
||||
User.last_name.ilike(search_term),
|
||||
User.email.ilike(search_term),
|
||||
User.company.ilike(search_term),
|
||||
User.position.ilike(search_term)
|
||||
)
|
||||
)
|
||||
|
||||
# Apply status filter
|
||||
if status == 'active':
|
||||
query = query.filter(User.is_active == True)
|
||||
elif status == 'inactive':
|
||||
query = query.filter(User.is_active == False)
|
||||
|
||||
# Apply role filter
|
||||
if role == 'admin':
|
||||
query = query.filter(User.is_admin == True)
|
||||
elif role == 'user':
|
||||
query = query.filter(User.is_admin == False)
|
||||
|
||||
# Order by creation date
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
# Get pagination
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
users = pagination.items
|
||||
|
||||
return render_template('contacts/list.html',
|
||||
users=users,
|
||||
pagination=pagination,
|
||||
current_user=current_user)
|
||||
|
||||
@contacts_bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def new_contact():
|
||||
result = admin_required()
|
||||
if result: return result
|
||||
form = UserForm()
|
||||
total_admins = User.query.filter_by(is_admin=True).count()
|
||||
if request.method == 'GET':
|
||||
form.is_admin.data = False # Ensure admin role is unchecked by default
|
||||
elif request.method == 'POST' and 'is_admin' not in request.form:
|
||||
form.is_admin.data = False # Explicitly set to False if not present in POST
|
||||
if form.validate_on_submit():
|
||||
# Check if a user with this email already exists
|
||||
existing_user = User.query.filter_by(email=form.email.data).first()
|
||||
if existing_user:
|
||||
flash('A user with this email already exists.', 'error')
|
||||
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
|
||||
|
||||
# Handle profile picture upload
|
||||
profile_picture = None
|
||||
file = request.files.get('profile_picture')
|
||||
if file and file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
file.save(file_path)
|
||||
profile_picture = filename
|
||||
|
||||
# Create new user account
|
||||
user = User(
|
||||
username=form.first_name.data,
|
||||
last_name=form.last_name.data,
|
||||
email=form.email.data,
|
||||
phone=form.phone.data,
|
||||
company=form.company.data,
|
||||
position=form.position.data,
|
||||
notes=form.notes.data,
|
||||
is_active=form.is_active.data,
|
||||
is_admin=form.is_admin.data,
|
||||
profile_picture=profile_picture
|
||||
)
|
||||
user.set_password('changeme') # Set a default password that must be changed
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash('User created successfully! They will need to set their password on first login.', 'success')
|
||||
return redirect(url_for('contacts.contacts_list'))
|
||||
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
|
||||
|
||||
@contacts_bp.route('/profile/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_profile():
|
||||
form = UserForm()
|
||||
total_admins = User.query.filter_by(is_admin=True).count()
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Check if trying to remove admin status from the only admin
|
||||
if not form.is_admin.data and current_user.is_admin:
|
||||
if total_admins <= 1:
|
||||
flash('There must be at least one admin user in the system.', 'error')
|
||||
return render_template('contacts/form.html', form=form, title='Edit Profile', total_admins=total_admins)
|
||||
|
||||
current_user.username = form.first_name.data
|
||||
current_user.last_name = form.last_name.data
|
||||
current_user.email = form.email.data
|
||||
current_user.phone = form.phone.data
|
||||
current_user.company = form.company.data
|
||||
current_user.position = form.position.data
|
||||
current_user.notes = form.notes.data
|
||||
current_user.is_active = form.is_active.data
|
||||
current_user.is_admin = form.is_admin.data
|
||||
# Set password if provided
|
||||
if form.new_password.data:
|
||||
current_user.set_password(form.new_password.data)
|
||||
db.session.commit()
|
||||
flash('Profile updated successfully!', 'success')
|
||||
return redirect(url_for('contacts.contacts_list'))
|
||||
|
||||
# Pre-fill the form with current user data
|
||||
if request.method == 'GET':
|
||||
form.first_name.data = current_user.username
|
||||
form.last_name.data = current_user.last_name
|
||||
form.email.data = current_user.email
|
||||
form.phone.data = current_user.phone
|
||||
form.company.data = current_user.company
|
||||
form.position.data = current_user.position
|
||||
form.notes.data = current_user.notes
|
||||
form.is_active.data = current_user.is_active
|
||||
form.is_admin.data = current_user.is_admin
|
||||
|
||||
return render_template('contacts/form.html', form=form, title='Edit Profile', total_admins=total_admins)
|
||||
|
||||
@contacts_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_contact(id):
|
||||
result = admin_required()
|
||||
if result: return result
|
||||
total_admins = User.query.filter_by(is_admin=True).count()
|
||||
user = User.query.get_or_404(id)
|
||||
form = UserForm()
|
||||
if request.method == 'GET':
|
||||
form.first_name.data = user.username
|
||||
form.last_name.data = user.last_name
|
||||
form.email.data = user.email
|
||||
form.phone.data = user.phone
|
||||
form.company.data = user.company
|
||||
form.position.data = user.position
|
||||
form.notes.data = user.notes
|
||||
form.is_active.data = user.is_active
|
||||
form.is_admin.data = user.is_admin
|
||||
if form.validate_on_submit():
|
||||
# Handle profile picture removal
|
||||
if 'remove_picture' in request.form:
|
||||
if user.profile_picture:
|
||||
# Delete the old profile picture file
|
||||
old_picture_path = os.path.join(UPLOAD_FOLDER, user.profile_picture)
|
||||
if os.path.exists(old_picture_path):
|
||||
os.remove(old_picture_path)
|
||||
user.profile_picture = None
|
||||
db.session.commit()
|
||||
flash('Profile picture removed successfully!', 'success')
|
||||
return redirect(url_for('contacts.edit_contact', id=user.id))
|
||||
|
||||
# Handle profile picture upload
|
||||
file = request.files.get('profile_picture')
|
||||
if file and file.filename:
|
||||
# Delete old profile picture if it exists
|
||||
if user.profile_picture:
|
||||
old_picture_path = os.path.join(UPLOAD_FOLDER, user.profile_picture)
|
||||
if os.path.exists(old_picture_path):
|
||||
os.remove(old_picture_path)
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
file.save(file_path)
|
||||
user.profile_picture = filename
|
||||
|
||||
# Prevent removing admin from the last admin
|
||||
if not form.is_admin.data and user.is_admin and total_admins <= 1:
|
||||
flash('There must be at least one admin user in the system.', 'error')
|
||||
return render_template('contacts/form.html', form=form, title='Edit User', total_admins=total_admins, user=user)
|
||||
# Check if the new email is already used by another user
|
||||
if form.email.data != user.email:
|
||||
existing_user = User.query.filter_by(email=form.email.data).first()
|
||||
if existing_user:
|
||||
flash('A user with this email already exists.', 'error')
|
||||
return render_template('contacts/form.html', form=form, title='Edit User', total_admins=total_admins, user=user)
|
||||
user.username = form.first_name.data
|
||||
user.last_name = form.last_name.data
|
||||
user.email = form.email.data
|
||||
user.phone = form.phone.data
|
||||
user.company = form.company.data
|
||||
user.position = form.position.data
|
||||
user.notes = form.notes.data
|
||||
user.is_active = form.is_active.data
|
||||
user.is_admin = form.is_admin.data
|
||||
# Set password if provided
|
||||
if form.new_password.data:
|
||||
user.set_password(form.new_password.data)
|
||||
db.session.commit()
|
||||
flash('User updated successfully!', 'success')
|
||||
return redirect(url_for('contacts.contacts_list'))
|
||||
return render_template('contacts/form.html', form=form, title='Edit User', total_admins=total_admins, user=user)
|
||||
|
||||
@contacts_bp.route('/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_contact(id):
|
||||
result = admin_required()
|
||||
if result: return result
|
||||
user = User.query.get_or_404(id)
|
||||
if user.email == current_user.email:
|
||||
flash('You cannot delete your own account.', 'error')
|
||||
return redirect(url_for('contacts.contacts_list'))
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash('User deleted successfully!', 'success')
|
||||
return redirect(url_for('contacts.contacts_list'))
|
||||
|
||||
@contacts_bp.route('/<int:id>/toggle-active', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_active(id):
|
||||
result = admin_required()
|
||||
if result: return result
|
||||
user = User.query.get_or_404(id)
|
||||
if user.email == current_user.email:
|
||||
flash('You cannot deactivate your own account.', 'error')
|
||||
return redirect(url_for('contacts.contacts_list'))
|
||||
user.is_active = not user.is_active
|
||||
db.session.commit()
|
||||
flash(f'User marked as {"active" if user.is_active else "inactive"}!', 'success')
|
||||
return redirect(url_for('contacts.contacts_list'))
|
||||
284
routes/main.py
Normal file
284
routes/main.py
Normal file
@@ -0,0 +1,284 @@
|
||||
from flask import render_template, Blueprint, redirect, url_for, request, flash
|
||||
from flask_login import current_user, login_required
|
||||
from models import User, db, Room, RoomFile, RoomMemberPermission
|
||||
import os
|
||||
from werkzeug.utils import secure_filename
|
||||
from sqlalchemy import func, case, literal_column, text
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def init_routes(main_bp):
|
||||
@main_bp.route('/')
|
||||
def home():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return render_template('home.html')
|
||||
|
||||
@main_bp.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
# Get 3 most recent users
|
||||
recent_contacts = User.query.order_by(User.created_at.desc()).limit(3).all()
|
||||
# Count active and inactive users
|
||||
active_count = User.query.filter_by(is_active=True).count()
|
||||
inactive_count = User.query.filter_by(is_active=False).count()
|
||||
|
||||
# Room count and size logic
|
||||
if current_user.is_admin:
|
||||
room_count = Room.query.count()
|
||||
# Get total file and folder counts for admin
|
||||
file_count = RoomFile.query.filter_by(type='file').count()
|
||||
folder_count = RoomFile.query.filter_by(type='folder').count()
|
||||
# Get total size of all files including trash
|
||||
total_size = db.session.query(func.sum(RoomFile.size)).filter(RoomFile.type == 'file').scalar() or 0
|
||||
# Get recent activity for all files
|
||||
recent_activity = db.session.query(
|
||||
RoomFile,
|
||||
Room,
|
||||
User
|
||||
).join(
|
||||
Room, RoomFile.room_id == Room.id
|
||||
).join(
|
||||
User, RoomFile.uploaded_by == User.id
|
||||
).order_by(
|
||||
RoomFile.uploaded_at.desc()
|
||||
).limit(10).all()
|
||||
|
||||
# Format the activity data
|
||||
formatted_activity = []
|
||||
for file, room, user in recent_activity:
|
||||
activity = {
|
||||
'name': file.name,
|
||||
'type': file.type,
|
||||
'room': room,
|
||||
'uploader': user,
|
||||
'uploaded_at': file.uploaded_at,
|
||||
'is_starred': current_user in file.starred_by,
|
||||
'is_deleted': file.deleted,
|
||||
'can_download': True # Admin can download everything
|
||||
}
|
||||
formatted_activity.append(activity)
|
||||
recent_activity = formatted_activity
|
||||
# Get storage usage by file type including trash
|
||||
storage_by_type = db.session.query(
|
||||
case(
|
||||
(RoomFile.name.like('%.%'),
|
||||
func.split_part(RoomFile.name, '.', -1)),
|
||||
else_=literal_column("'unknown'")
|
||||
).label('extension'),
|
||||
func.count(RoomFile.id).label('count'),
|
||||
func.sum(RoomFile.size).label('total_size')
|
||||
).filter(
|
||||
RoomFile.type == 'file'
|
||||
).group_by('extension').all()
|
||||
|
||||
# Get trash and starred stats for admin
|
||||
trash_count = RoomFile.query.filter_by(deleted=True).count()
|
||||
starred_count = RoomFile.query.filter(RoomFile.starred_by.contains(current_user)).count()
|
||||
# Get oldest trash date and total trash size
|
||||
oldest_trash = RoomFile.query.filter_by(deleted=True).order_by(RoomFile.deleted_at.asc()).first()
|
||||
oldest_trash_date = oldest_trash.deleted_at.strftime('%Y-%m-%d') if oldest_trash else None
|
||||
trash_size = db.session.query(db.func.sum(RoomFile.size)).filter(RoomFile.deleted==True).scalar() or 0
|
||||
|
||||
# Get files that will be deleted in next 7 days
|
||||
seven_days_from_now = datetime.utcnow() + timedelta(days=7)
|
||||
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||
pending_deletion = RoomFile.query.filter(
|
||||
RoomFile.deleted==True,
|
||||
RoomFile.deleted_at <= thirty_days_ago,
|
||||
RoomFile.deleted_at > thirty_days_ago - timedelta(days=7)
|
||||
).count()
|
||||
|
||||
# Get trash file type breakdown
|
||||
trash_by_type = db.session.query(
|
||||
case(
|
||||
(RoomFile.name.like('%.%'),
|
||||
func.split_part(RoomFile.name, '.', -1)),
|
||||
else_=literal_column("'unknown'")
|
||||
).label('extension'),
|
||||
func.count(RoomFile.id).label('count')
|
||||
).filter(
|
||||
RoomFile.deleted==True
|
||||
).group_by('extension').all()
|
||||
else:
|
||||
# Get rooms the user has access to
|
||||
accessible_rooms = Room.query.filter(Room.members.any(id=current_user.id)).all()
|
||||
room_count = len(accessible_rooms)
|
||||
# Get file and folder counts for accessible rooms
|
||||
room_ids = [room.id for room in accessible_rooms]
|
||||
file_count = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.type == 'file').count()
|
||||
folder_count = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.type == 'folder').count()
|
||||
# Get total size of files in accessible rooms including trash
|
||||
total_size = db.session.query(func.sum(RoomFile.size)).filter(
|
||||
RoomFile.room_id.in_(room_ids),
|
||||
RoomFile.type == 'file'
|
||||
).scalar() or 0
|
||||
# Get recent activity for accessible rooms
|
||||
recent_activity = db.session.query(
|
||||
RoomFile,
|
||||
Room,
|
||||
User
|
||||
).join(
|
||||
Room, RoomFile.room_id == Room.id
|
||||
).join(
|
||||
User, RoomFile.uploaded_by == User.id
|
||||
).filter(
|
||||
RoomFile.room_id.in_(room_ids)
|
||||
).order_by(
|
||||
RoomFile.uploaded_at.desc()
|
||||
).limit(10).all()
|
||||
|
||||
# Format the activity data
|
||||
formatted_activity = []
|
||||
user_perms = {p.room_id: p for p in RoomMemberPermission.query.filter(
|
||||
RoomMemberPermission.room_id.in_(room_ids),
|
||||
RoomMemberPermission.user_id==current_user.id
|
||||
).all()}
|
||||
|
||||
for file, room, user in recent_activity:
|
||||
perm = user_perms.get(room.id)
|
||||
activity = {
|
||||
'name': file.name,
|
||||
'type': file.type,
|
||||
'room': room,
|
||||
'uploader': user,
|
||||
'uploaded_at': file.uploaded_at,
|
||||
'is_starred': current_user in file.starred_by,
|
||||
'is_deleted': file.deleted,
|
||||
'can_download': perm.can_download if perm else False
|
||||
}
|
||||
formatted_activity.append(activity)
|
||||
recent_activity = formatted_activity
|
||||
# Get storage usage by file type for accessible rooms including trash
|
||||
storage_by_type = db.session.query(
|
||||
case(
|
||||
(RoomFile.name.like('%.%'),
|
||||
func.split_part(RoomFile.name, '.', -1)),
|
||||
else_=literal_column("'unknown'")
|
||||
).label('extension'),
|
||||
func.count(RoomFile.id).label('count'),
|
||||
func.sum(RoomFile.size).label('total_size')
|
||||
).filter(
|
||||
RoomFile.room_id.in_(room_ids),
|
||||
RoomFile.type == 'file'
|
||||
).group_by('extension').all()
|
||||
|
||||
# Get trash and starred stats for user's accessible rooms
|
||||
trash_count = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.deleted==True).count()
|
||||
starred_count = RoomFile.query.filter(
|
||||
RoomFile.room_id.in_(room_ids),
|
||||
RoomFile.starred_by.contains(current_user)
|
||||
).count()
|
||||
# Get oldest trash date and total trash size for user's rooms
|
||||
oldest_trash = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.deleted==True).order_by(RoomFile.deleted_at.asc()).first()
|
||||
oldest_trash_date = oldest_trash.deleted_at.strftime('%Y-%m-%d') if oldest_trash else None
|
||||
trash_size = db.session.query(db.func.sum(RoomFile.size)).filter(RoomFile.room_id.in_(room_ids), RoomFile.deleted==True).scalar() or 0
|
||||
|
||||
# Get files that will be deleted in next 7 days for user's rooms
|
||||
seven_days_from_now = datetime.utcnow() + timedelta(days=7)
|
||||
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||
pending_deletion = RoomFile.query.filter(
|
||||
RoomFile.room_id.in_(room_ids),
|
||||
RoomFile.deleted==True,
|
||||
RoomFile.deleted_at <= thirty_days_ago,
|
||||
RoomFile.deleted_at > thirty_days_ago - timedelta(days=7)
|
||||
).count()
|
||||
|
||||
# Get trash file type breakdown for user's rooms
|
||||
trash_by_type = db.session.query(
|
||||
case(
|
||||
(RoomFile.name.like('%.%'),
|
||||
func.split_part(RoomFile.name, '.', -1)),
|
||||
else_=literal_column("'unknown'")
|
||||
).label('extension'),
|
||||
func.count(RoomFile.id).label('count')
|
||||
).filter(
|
||||
RoomFile.room_id.in_(room_ids),
|
||||
RoomFile.deleted==True
|
||||
).group_by('extension').all()
|
||||
|
||||
return render_template('dashboard.html',
|
||||
recent_contacts=recent_contacts,
|
||||
active_count=active_count,
|
||||
inactive_count=inactive_count,
|
||||
room_count=room_count,
|
||||
file_count=file_count,
|
||||
folder_count=folder_count,
|
||||
total_size=total_size,
|
||||
recent_activity=recent_activity,
|
||||
storage_by_type=storage_by_type,
|
||||
trash_count=trash_count,
|
||||
starred_count=starred_count,
|
||||
oldest_trash_date=oldest_trash_date,
|
||||
trash_size=trash_size,
|
||||
pending_deletion=pending_deletion,
|
||||
trash_by_type=trash_by_type)
|
||||
|
||||
UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads', 'profile_pics')
|
||||
if not os.path.exists(UPLOAD_FOLDER):
|
||||
os.makedirs(UPLOAD_FOLDER)
|
||||
|
||||
@main_bp.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
if request.method == 'POST':
|
||||
# Handle profile picture removal
|
||||
if 'remove_picture' in request.form:
|
||||
if current_user.profile_picture:
|
||||
# Delete the old profile picture file
|
||||
old_picture_path = os.path.join(UPLOAD_FOLDER, current_user.profile_picture)
|
||||
if os.path.exists(old_picture_path):
|
||||
os.remove(old_picture_path)
|
||||
current_user.profile_picture = None
|
||||
db.session.commit()
|
||||
flash('Profile picture removed successfully!', 'success')
|
||||
return redirect(url_for('main.profile'))
|
||||
|
||||
new_email = request.form.get('email')
|
||||
# Check if the new email is already used by another user
|
||||
if new_email != current_user.email:
|
||||
existing_user = User.query.filter_by(email=new_email).first()
|
||||
if existing_user:
|
||||
flash('A user with this email already exists.', 'error')
|
||||
return render_template('profile.html')
|
||||
# Handle profile picture upload
|
||||
file = request.files.get('profile_picture')
|
||||
if file and file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
file.save(file_path)
|
||||
current_user.profile_picture = filename
|
||||
# Update user information
|
||||
current_user.username = request.form.get('first_name')
|
||||
current_user.last_name = request.form.get('last_name')
|
||||
current_user.email = new_email
|
||||
current_user.phone = request.form.get('phone')
|
||||
current_user.company = request.form.get('company')
|
||||
current_user.position = request.form.get('position')
|
||||
current_user.notes = request.form.get('notes')
|
||||
# Handle password change if provided
|
||||
new_password = request.form.get('new_password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
if new_password:
|
||||
if new_password != confirm_password:
|
||||
flash('Passwords do not match.', 'error')
|
||||
return render_template('profile.html')
|
||||
current_user.set_password(new_password)
|
||||
flash('Password updated successfully.', 'success')
|
||||
try:
|
||||
db.session.commit()
|
||||
flash('Profile updated successfully!', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('An error occurred while updating your profile.', 'error')
|
||||
return redirect(url_for('main.profile'))
|
||||
return render_template('profile.html')
|
||||
|
||||
@main_bp.route('/starred')
|
||||
@login_required
|
||||
def starred():
|
||||
return render_template('starred.html')
|
||||
|
||||
@main_bp.route('/trash')
|
||||
@login_required
|
||||
def trash():
|
||||
return render_template('trash.html')
|
||||
668
routes/room_files.py
Normal file
668
routes/room_files.py
Normal file
@@ -0,0 +1,668 @@
|
||||
from flask import Blueprint, jsonify, request, abort, send_from_directory, send_file
|
||||
from flask_login import login_required, current_user
|
||||
import os
|
||||
from models import Room, RoomMemberPermission, RoomFile, TrashedFile, db
|
||||
from werkzeug.utils import secure_filename, safe_join
|
||||
import time
|
||||
import shutil
|
||||
import io
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
||||
room_files_bp = Blueprint('room_files', __name__, url_prefix='/api/rooms')
|
||||
|
||||
DATA_ROOT = '/data/rooms' # This should be a Docker volume
|
||||
|
||||
ALLOWED_EXTENSIONS = {
|
||||
# Documents
|
||||
'pdf', 'docx', 'doc', 'txt', 'rtf', 'odt', 'md', 'csv',
|
||||
# Spreadsheets
|
||||
'xlsx', 'xls', 'ods', 'xlsm',
|
||||
# Presentations
|
||||
'pptx', 'ppt', 'odp',
|
||||
# Images
|
||||
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'tiff',
|
||||
# Archives
|
||||
'zip', 'rar', '7z', 'tar', 'gz',
|
||||
# Code/Text
|
||||
'py', 'js', 'html', 'css', 'json', 'xml', 'sql', 'sh', 'bat',
|
||||
# Audio
|
||||
'mp3', 'wav', 'ogg', 'm4a', 'flac',
|
||||
# Video
|
||||
'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm',
|
||||
# CAD/Design
|
||||
'dwg', 'dxf', 'ai', 'psd', 'eps', 'indd',
|
||||
# Other
|
||||
'eml', 'msg', 'vcf', 'ics'
|
||||
}
|
||||
|
||||
def get_room_dir(room_id):
|
||||
return os.path.join(DATA_ROOT, str(room_id))
|
||||
|
||||
def user_has_permission(room, perm_name):
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
perm = RoomMemberPermission.query.filter_by(room_id=room.id, user_id=current_user.id).first()
|
||||
return getattr(perm, perm_name, False) if perm else False
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def clean_path(path):
|
||||
if not path:
|
||||
return ''
|
||||
return path.strip('/\\')
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/files', methods=['GET'])
|
||||
@login_required
|
||||
def list_room_files(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_view'):
|
||||
abort(403)
|
||||
|
||||
path = request.args.get('path', '')
|
||||
path = clean_path(path)
|
||||
|
||||
# Get files in the current path
|
||||
files = RoomFile.query.filter_by(room_id=room_id, path=path, deleted=False).all()
|
||||
|
||||
# Debug: Check user permissions
|
||||
if not current_user.is_admin:
|
||||
perm = RoomMemberPermission.query.filter_by(room_id=room.id, user_id=current_user.id).first()
|
||||
print("=== User Permissions ===")
|
||||
print(f" - can_view: {getattr(perm, 'can_view', False) if perm else False}")
|
||||
print(f" - can_download: {getattr(perm, 'can_download', False) if perm else False}")
|
||||
print(f" - can_upload: {getattr(perm, 'can_upload', False) if perm else False}")
|
||||
print(f" - can_delete: {getattr(perm, 'can_delete', False) if perm else False}")
|
||||
print(f" - can_rename: {getattr(perm, 'can_rename', False) if perm else False}")
|
||||
print(f" - can_move: {getattr(perm, 'can_move', False) if perm else False}")
|
||||
print(f" - can_share: {getattr(perm, 'can_share', False) if perm else False}")
|
||||
print("-------------------")
|
||||
|
||||
result = []
|
||||
for f in files:
|
||||
uploader_full_name = None
|
||||
uploader_profile_pic = None
|
||||
if f.uploader:
|
||||
uploader_full_name = f.uploader.username
|
||||
if getattr(f.uploader, 'last_name', None):
|
||||
uploader_full_name += ' ' + f.uploader.last_name
|
||||
uploader_profile_pic = f.uploader.profile_picture if getattr(f.uploader, 'profile_picture', None) else None
|
||||
result.append({
|
||||
'name': f.name,
|
||||
'type': f.type,
|
||||
'size': f.size if f.type == 'file' else '-',
|
||||
'modified': f.modified,
|
||||
'uploaded_by': uploader_full_name,
|
||||
'uploader_profile_pic': uploader_profile_pic,
|
||||
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
|
||||
'path': f.path,
|
||||
'starred': current_user in f.starred_by
|
||||
})
|
||||
print(f"Returning {len(result)} files") # Debug log
|
||||
return jsonify(result)
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/files/upload', methods=['POST'])
|
||||
@login_required
|
||||
def upload_room_file(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_upload'):
|
||||
abort(403)
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file part'}), 400
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({'error': 'No selected file'}), 400
|
||||
if not allowed_file(file.filename):
|
||||
return jsonify({'error': 'File type not allowed'}), 400
|
||||
filename = secure_filename(file.filename)
|
||||
room_dir = get_room_dir(room_id)
|
||||
rel_path = clean_path(request.form.get('path', ''))
|
||||
target_dir = os.path.join(room_dir, rel_path) if rel_path else room_dir
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
file_path = os.path.join(target_dir, filename)
|
||||
|
||||
# Check for overwrite flag
|
||||
overwrite = request.form.get('overwrite', 'false').lower() == 'true'
|
||||
|
||||
# First check for non-deleted files
|
||||
existing_file = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path, deleted=False).first()
|
||||
if existing_file and not overwrite:
|
||||
return jsonify({'error': 'A file with this name already exists in this location', 'conflict': True}), 409
|
||||
|
||||
# Then check for deleted files
|
||||
trashed_file = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path, deleted=True).first()
|
||||
if trashed_file:
|
||||
# If we're not overwriting, return conflict
|
||||
if not overwrite:
|
||||
return jsonify({'error': 'A file with this name exists in the trash', 'conflict': True}), 409
|
||||
|
||||
# If we are overwriting, delete the trashed file record
|
||||
db.session.delete(trashed_file)
|
||||
db.session.commit()
|
||||
existing_file = None
|
||||
|
||||
file.save(file_path)
|
||||
stat = os.stat(file_path)
|
||||
if existing_file:
|
||||
# Overwrite: update the RoomFile record
|
||||
existing_file.size = stat.st_size
|
||||
existing_file.modified = stat.st_mtime
|
||||
existing_file.uploaded_by = current_user.id
|
||||
existing_file.uploaded_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'filename': filename, 'overwritten': True})
|
||||
else:
|
||||
rf = RoomFile(
|
||||
room_id=room_id,
|
||||
name=filename,
|
||||
path=rel_path,
|
||||
type='file',
|
||||
size=stat.st_size,
|
||||
modified=stat.st_mtime,
|
||||
uploaded_by=current_user.id,
|
||||
uploaded_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(rf)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'filename': filename})
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/files/<filename>', methods=['GET'])
|
||||
@login_required
|
||||
def download_room_file(room_id, filename):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_download'):
|
||||
abort(403)
|
||||
rel_path = clean_path(request.args.get('path', ''))
|
||||
# Lookup in RoomFile
|
||||
rf = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path).first()
|
||||
if not rf or rf.type != 'file':
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
room_dir = get_room_dir(room_id)
|
||||
file_path = os.path.join(room_dir, rel_path, filename) if rel_path else os.path.join(room_dir, filename)
|
||||
if not os.path.exists(file_path):
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
return send_from_directory(os.path.dirname(file_path), filename, as_attachment=True)
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/files/<path:filename>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_file(room_id, filename):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_delete'):
|
||||
abort(403)
|
||||
|
||||
rel_path = clean_path(request.args.get('path', ''))
|
||||
|
||||
# Lookup in RoomFile
|
||||
rf = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path).first()
|
||||
if not rf:
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
# Mark as deleted and record who deleted it and when
|
||||
rf.deleted = True
|
||||
rf.deleted_by = current_user.id
|
||||
rf.deleted_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/folders', methods=['POST'])
|
||||
@login_required
|
||||
def create_room_folder(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_upload'):
|
||||
abort(403)
|
||||
data = request.get_json()
|
||||
folder_name = data.get('name', '').strip()
|
||||
rel_path = clean_path(data.get('path', ''))
|
||||
|
||||
if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name.startswith('.'):
|
||||
return jsonify({'error': 'Invalid folder name'}), 400
|
||||
|
||||
room_dir = get_room_dir(room_id)
|
||||
target_dir = os.path.join(room_dir, rel_path) if rel_path else room_dir
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
folder_path = os.path.join(target_dir, folder_name)
|
||||
|
||||
# First check for trashed folder
|
||||
trashed_folder = RoomFile.query.filter_by(
|
||||
room_id=room_id,
|
||||
name=folder_name,
|
||||
path=rel_path,
|
||||
type='folder',
|
||||
deleted=True
|
||||
).first()
|
||||
|
||||
if trashed_folder:
|
||||
return jsonify({'error': 'A folder with this name exists in the trash'}), 409
|
||||
|
||||
# Then check for existing folder in current location
|
||||
existing_folder = RoomFile.query.filter_by(
|
||||
room_id=room_id,
|
||||
name=folder_name,
|
||||
path=rel_path,
|
||||
type='folder',
|
||||
deleted=False
|
||||
).first()
|
||||
|
||||
if existing_folder:
|
||||
return jsonify({'error': 'A folder with this name already exists in this location'}), 400
|
||||
|
||||
if os.path.exists(folder_path):
|
||||
return jsonify({'error': 'A folder with this name already exists in this location'}), 400
|
||||
|
||||
os.makedirs(folder_path)
|
||||
# Add RoomFile entry
|
||||
stat = os.stat(folder_path)
|
||||
rf = RoomFile(
|
||||
room_id=room_id,
|
||||
name=folder_name,
|
||||
path=rel_path,
|
||||
type='folder',
|
||||
size=None,
|
||||
modified=stat.st_mtime,
|
||||
uploaded_by=current_user.id,
|
||||
uploaded_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(rf)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'name': folder_name})
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/rename', methods=['POST'])
|
||||
@login_required
|
||||
def rename_room_file(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Allow rename if user can upload or delete
|
||||
if not (user_has_permission(room, 'can_upload') or user_has_permission(room, 'can_delete')):
|
||||
abort(403)
|
||||
data = request.get_json()
|
||||
old_name = data.get('old_name', '').strip()
|
||||
new_name = data.get('new_name', '').strip()
|
||||
rel_path = clean_path(data.get('path', ''))
|
||||
if not old_name or not new_name or '/' in new_name or '\\' in new_name or new_name.startswith('.'):
|
||||
return jsonify({'error': 'Invalid name'}), 400
|
||||
# Lookup in RoomFile
|
||||
rf = RoomFile.query.filter_by(room_id=room_id, name=old_name, path=rel_path).first()
|
||||
if not rf:
|
||||
return jsonify({'error': 'Original file/folder not found'}), 404
|
||||
room_dir = get_room_dir(room_id)
|
||||
base_dir = os.path.join(room_dir, rel_path) if rel_path else room_dir
|
||||
old_path = os.path.join(base_dir, old_name)
|
||||
new_path = os.path.join(base_dir, new_name)
|
||||
if not os.path.exists(old_path):
|
||||
return jsonify({'error': 'Original file/folder not found'}), 404
|
||||
if os.path.exists(new_path):
|
||||
return jsonify({'error': 'A file or folder with the new name already exists'}), 400
|
||||
# Prevent file extension change for files
|
||||
if os.path.isfile(old_path):
|
||||
old_ext = os.path.splitext(old_name)[1].lower()
|
||||
new_ext = os.path.splitext(new_name)[1].lower()
|
||||
if old_ext != new_ext:
|
||||
return jsonify({'error': 'File extension cannot be changed'}), 400
|
||||
os.rename(old_path, new_path)
|
||||
# Update RoomFile entry
|
||||
rf.name = new_name
|
||||
rf.modified = os.path.getmtime(new_path)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'old_name': old_name, 'new_name': new_name})
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/download-zip', methods=['POST'])
|
||||
@login_required
|
||||
def download_zip(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_view'):
|
||||
abort(403)
|
||||
data = request.get_json()
|
||||
items = data.get('items', [])
|
||||
if not items or not isinstance(items, list):
|
||||
return jsonify({'error': 'No items selected'}), 400
|
||||
room_dir = get_room_dir(room_id)
|
||||
mem_zip = io.BytesIO()
|
||||
with zipfile.ZipFile(mem_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for item in items:
|
||||
name = item.get('name')
|
||||
rel_path = item.get('path', '').strip('/\\')
|
||||
abs_path = os.path.join(room_dir, rel_path, name) if rel_path else os.path.join(room_dir, name)
|
||||
if not os.path.exists(abs_path):
|
||||
continue
|
||||
if os.path.isfile(abs_path):
|
||||
zf.write(abs_path, arcname=name)
|
||||
elif os.path.isdir(abs_path):
|
||||
for root, dirs, files in os.walk(abs_path):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, room_dir)
|
||||
zf.write(file_path, arcname=arcname)
|
||||
mem_zip.seek(0)
|
||||
return send_file(mem_zip, mimetype='application/zip', as_attachment=True, download_name='download.zip')
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/search', methods=['GET'])
|
||||
@login_required
|
||||
def search_room_files(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_view'):
|
||||
abort(403)
|
||||
query = request.args.get('query', '').strip().lower()
|
||||
# Search RoomFile for this room
|
||||
files = RoomFile.query.filter(RoomFile.room_id==room_id).all()
|
||||
matches = []
|
||||
for f in files:
|
||||
if query in f.name.lower():
|
||||
matches.append({
|
||||
'name': f.name,
|
||||
'type': f.type,
|
||||
'size': f.size if f.type == 'file' else '-',
|
||||
'modified': f.modified,
|
||||
'uploaded_by': f.uploader.username if f.uploader else None,
|
||||
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
|
||||
'path': f.path,
|
||||
})
|
||||
return jsonify(matches)
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/move', methods=['POST'])
|
||||
@login_required
|
||||
def move_room_file(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_move'):
|
||||
abort(403)
|
||||
data = request.get_json()
|
||||
filename = data.get('filename', '').strip()
|
||||
source_path = clean_path(data.get('source_path', ''))
|
||||
target_path = clean_path(data.get('target_path', ''))
|
||||
|
||||
if not filename or '/' in filename or '\\' in filename or filename.startswith('.'):
|
||||
return jsonify({'error': 'Invalid filename'}), 400
|
||||
|
||||
# Lookup in RoomFile
|
||||
rf = RoomFile.query.filter_by(room_id=room_id, name=filename, path=source_path).first()
|
||||
if not rf:
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
room_dir = get_room_dir(room_id)
|
||||
source_dir = os.path.join(room_dir, source_path) if source_path else room_dir
|
||||
target_dir = os.path.join(room_dir, target_path) if target_path else room_dir
|
||||
|
||||
source_file_path = os.path.join(source_dir, filename)
|
||||
target_file_path = os.path.join(target_dir, filename)
|
||||
|
||||
if not os.path.exists(source_file_path):
|
||||
return jsonify({'error': 'Source file not found'}), 404
|
||||
|
||||
if os.path.exists(target_file_path):
|
||||
return jsonify({'error': 'A file with this name already exists in the target location'}), 400
|
||||
|
||||
# Create target directory if it doesn't exist
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
# Move the file
|
||||
shutil.move(source_file_path, target_file_path)
|
||||
|
||||
# Update RoomFile entry
|
||||
rf.path = target_path
|
||||
rf.modified = os.path.getmtime(target_file_path)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/folders', methods=['GET'])
|
||||
@login_required
|
||||
def list_room_folders(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_view'):
|
||||
abort(403)
|
||||
|
||||
# Get all files in the room that are not deleted
|
||||
files = RoomFile.query.filter_by(room_id=room_id, deleted=False).all()
|
||||
|
||||
# Extract unique folder paths
|
||||
folders = set()
|
||||
for f in files:
|
||||
if f.type == 'folder':
|
||||
full_path = f.path + '/' + f.name if f.path else f.name
|
||||
folders.add(full_path)
|
||||
|
||||
# Convert to sorted list
|
||||
folder_list = sorted(list(folders))
|
||||
|
||||
return jsonify(folder_list)
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/star', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_star(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_view'):
|
||||
abort(403)
|
||||
|
||||
data = request.get_json()
|
||||
filename = data.get('filename', '').strip()
|
||||
rel_path = clean_path(data.get('path', ''))
|
||||
|
||||
if not filename:
|
||||
return jsonify({'error': 'Invalid filename'}), 400
|
||||
|
||||
# Lookup in RoomFile
|
||||
rf = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path).first()
|
||||
if not rf:
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
# Check if the file is already starred by this user
|
||||
is_starred = current_user in rf.starred_by
|
||||
|
||||
if is_starred:
|
||||
# Unstar the file
|
||||
rf.starred_by.remove(current_user)
|
||||
else:
|
||||
# Star the file
|
||||
rf.starred_by.append(current_user)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'starred': not is_starred})
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/starred', methods=['GET'])
|
||||
@login_required
|
||||
def get_starred_files(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_view'):
|
||||
abort(403)
|
||||
|
||||
# Get all starred files in the room
|
||||
files = RoomFile.query.filter_by(room_id=room_id, starred=True).all()
|
||||
|
||||
result = []
|
||||
for f in files:
|
||||
uploader_full_name = None
|
||||
uploader_profile_pic = None
|
||||
if f.uploader:
|
||||
uploader_full_name = f.uploader.username
|
||||
if getattr(f.uploader, 'last_name', None):
|
||||
uploader_full_name += ' ' + f.uploader.last_name
|
||||
uploader_profile_pic = f.uploader.profile_picture if getattr(f.uploader, 'profile_picture', None) else None
|
||||
result.append({
|
||||
'name': f.name,
|
||||
'type': f.type,
|
||||
'size': f.size if f.type == 'file' else '-',
|
||||
'modified': f.modified,
|
||||
'uploaded_by': uploader_full_name,
|
||||
'uploader_profile_pic': uploader_profile_pic,
|
||||
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
|
||||
'path': f.path,
|
||||
'starred': f.starred
|
||||
})
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@room_files_bp.route('/starred', methods=['GET'])
|
||||
@login_required
|
||||
def get_all_starred_files():
|
||||
# Get all rooms the user has access to
|
||||
if current_user.is_admin:
|
||||
rooms = Room.query.all()
|
||||
else:
|
||||
rooms = Room.query.filter(Room.members.any(id=current_user.id)).all()
|
||||
|
||||
room_ids = [room.id for room in rooms]
|
||||
room_names = {room.id: room.name for room in rooms}
|
||||
|
||||
# Get all files starred by the current user from accessible rooms
|
||||
files = RoomFile.query.filter(
|
||||
RoomFile.room_id.in_(room_ids),
|
||||
RoomFile.starred_by.contains(current_user)
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for f in files:
|
||||
uploader_full_name = None
|
||||
uploader_profile_pic = None
|
||||
if f.uploader:
|
||||
uploader_full_name = f.uploader.username
|
||||
if getattr(f.uploader, 'last_name', None):
|
||||
uploader_full_name += ' ' + f.uploader.last_name
|
||||
uploader_profile_pic = f.uploader.profile_picture if getattr(f.uploader, 'profile_picture', None) else None
|
||||
result.append({
|
||||
'name': f.name,
|
||||
'type': f.type,
|
||||
'size': f.size if f.type == 'file' else '-',
|
||||
'modified': f.modified,
|
||||
'uploaded_by': uploader_full_name,
|
||||
'uploader_profile_pic': uploader_profile_pic,
|
||||
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
|
||||
'path': f.path,
|
||||
'starred': True,
|
||||
'room_id': f.room_id,
|
||||
'room_name': room_names.get(f.room_id)
|
||||
})
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@room_files_bp.route('/trash', methods=['GET'])
|
||||
@login_required
|
||||
def get_trash_files():
|
||||
# Get all rooms the user has access to
|
||||
if current_user.is_admin:
|
||||
rooms = Room.query.all()
|
||||
else:
|
||||
rooms = Room.query.filter(Room.members.any(id=current_user.id)).all()
|
||||
|
||||
room_ids = [room.id for room in rooms]
|
||||
room_names = {room.id: room.name for room in rooms}
|
||||
|
||||
# Get all deleted files from accessible rooms
|
||||
files = RoomFile.query.filter(
|
||||
RoomFile.room_id.in_(room_ids),
|
||||
RoomFile.deleted == True
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for f in files:
|
||||
uploader_full_name = None
|
||||
uploader_profile_pic = None
|
||||
if f.uploader:
|
||||
uploader_full_name = f.uploader.username
|
||||
if getattr(f.uploader, 'last_name', None):
|
||||
uploader_full_name += ' ' + f.uploader.last_name
|
||||
uploader_profile_pic = f.uploader.profile_picture if getattr(f.uploader, 'profile_picture', None) else None
|
||||
|
||||
deleter_full_name = None
|
||||
if f.deleter:
|
||||
deleter_full_name = f.deleter.username
|
||||
if getattr(f.deleter, 'last_name', None):
|
||||
deleter_full_name += ' ' + f.deleter.last_name
|
||||
|
||||
# Check if user has delete permission in this room
|
||||
room = Room.query.get(f.room_id)
|
||||
has_delete_permission = user_has_permission(room, 'can_delete') if room else False
|
||||
|
||||
# Check if user can restore this file
|
||||
can_restore = has_delete_permission and f.deleted_by == current_user.id
|
||||
|
||||
result.append({
|
||||
'name': f.name,
|
||||
'type': f.type,
|
||||
'size': f.size if f.type == 'file' else '-',
|
||||
'modified': f.modified,
|
||||
'uploaded_by': uploader_full_name,
|
||||
'uploader_profile_pic': uploader_profile_pic,
|
||||
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
|
||||
'path': f.path,
|
||||
'room_id': f.room_id,
|
||||
'room_name': room_names.get(f.room_id),
|
||||
'deleted_by': deleter_full_name,
|
||||
'deleted_at': f.deleted_at.isoformat() if f.deleted_at else None,
|
||||
'can_restore': can_restore
|
||||
})
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/restore', methods=['POST'])
|
||||
@login_required
|
||||
def restore_file(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Check for delete permission instead of view permission
|
||||
if not user_has_permission(room, 'can_delete'):
|
||||
abort(403)
|
||||
|
||||
data = request.get_json()
|
||||
filename = data.get('filename', '').strip()
|
||||
rel_path = clean_path(data.get('path', ''))
|
||||
|
||||
if not filename:
|
||||
return jsonify({'error': 'Invalid filename'}), 400
|
||||
|
||||
# Lookup in RoomFile
|
||||
rf = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path).first()
|
||||
if not rf:
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
# Check if the current user was the one who deleted the file
|
||||
if rf.deleted_by != current_user.id:
|
||||
return jsonify({'error': 'You can only restore files that you deleted'}), 403
|
||||
|
||||
# Restore file by setting deleted to False
|
||||
rf.deleted = False
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
@room_files_bp.route('/<int:room_id>/delete-permanent', methods=['POST'])
|
||||
@login_required
|
||||
def delete_permanent(room_id):
|
||||
# Only allow admin users to permanently delete files
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
|
||||
room = Room.query.get_or_404(room_id)
|
||||
data = request.get_json()
|
||||
filename = data.get('filename', '').strip()
|
||||
rel_path = clean_path(data.get('path', ''))
|
||||
|
||||
if not filename:
|
||||
return jsonify({'error': 'Invalid filename'}), 400
|
||||
|
||||
# If filename is '*', delete all deleted files in the room
|
||||
if filename == '*':
|
||||
files_to_delete = RoomFile.query.filter_by(room_id=room_id, deleted=True).all()
|
||||
else:
|
||||
# Lookup specific file
|
||||
files_to_delete = [RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path).first()]
|
||||
if not files_to_delete[0]:
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
for rf in files_to_delete:
|
||||
if not rf:
|
||||
continue
|
||||
|
||||
# Delete the file from storage if it's a file
|
||||
if rf.type == 'file':
|
||||
try:
|
||||
file_path = os.path.join(get_room_dir(room_id), rf.path, rf.name)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
print(f"Error deleting file from storage: {e}")
|
||||
|
||||
# Delete the database record
|
||||
db.session.delete(rf)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
121
routes/room_members.py
Normal file
121
routes/room_members.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from flask import Blueprint, jsonify, request, abort
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Room, User, RoomMemberPermission
|
||||
from utils import user_has_permission
|
||||
|
||||
room_members_bp = Blueprint('room_members', __name__)
|
||||
|
||||
@room_members_bp.route('/<int:room_id>/members', methods=['GET'])
|
||||
@login_required
|
||||
def list_room_members(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_view'):
|
||||
abort(403)
|
||||
|
||||
members = []
|
||||
for member in room.members:
|
||||
permission = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=member.id).first()
|
||||
members.append({
|
||||
'id': member.id,
|
||||
'username': member.username,
|
||||
'last_name': member.last_name,
|
||||
'email': member.email,
|
||||
'profile_picture': member.profile_picture,
|
||||
'permissions': {
|
||||
'can_view': permission.can_view if permission else False,
|
||||
'can_download': permission.can_download if permission else False,
|
||||
'can_upload': permission.can_upload if permission else False,
|
||||
'can_delete': permission.can_delete if permission else False,
|
||||
'can_rename': permission.can_rename if permission else False,
|
||||
'can_move': permission.can_move if permission else False,
|
||||
'can_share': permission.can_share if permission else False
|
||||
}
|
||||
})
|
||||
|
||||
return jsonify(members)
|
||||
|
||||
@room_members_bp.route('/<int:room_id>/members', methods=['POST'])
|
||||
@login_required
|
||||
def add_room_member(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_share'):
|
||||
abort(403)
|
||||
|
||||
data = request.get_json()
|
||||
user_id = data.get('user_id')
|
||||
permissions = data.get('permissions', {})
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'error': 'User ID is required'}), 400
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
# Add user to room members
|
||||
if user not in room.members:
|
||||
room.members.append(user)
|
||||
|
||||
# Update permissions
|
||||
permission = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
|
||||
if not permission:
|
||||
permission = RoomMemberPermission(room_id=room_id, user_id=user_id)
|
||||
db.session.add(permission)
|
||||
|
||||
permission.can_view = permissions.get('can_view', True)
|
||||
permission.can_download = permissions.get('can_download', False)
|
||||
permission.can_upload = permissions.get('can_upload', False)
|
||||
permission.can_delete = permissions.get('can_delete', False)
|
||||
permission.can_rename = permissions.get('can_rename', False)
|
||||
permission.can_move = permissions.get('can_move', False)
|
||||
permission.can_share = permissions.get('can_share', False)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
@room_members_bp.route('/<int:room_id>/members/<int:user_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def remove_room_member(room_id, user_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_share'):
|
||||
abort(403)
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
# Remove user from room members
|
||||
if user in room.members:
|
||||
room.members.remove(user)
|
||||
|
||||
# Remove permissions
|
||||
permission = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
|
||||
if permission:
|
||||
db.session.delete(permission)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
@room_members_bp.route('/<int:room_id>/members/<int:user_id>/permissions', methods=['PUT'])
|
||||
@login_required
|
||||
def update_member_permissions(room_id, user_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_share'):
|
||||
abort(403)
|
||||
|
||||
data = request.get_json()
|
||||
permissions = data.get('permissions', {})
|
||||
|
||||
permission = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
|
||||
if not permission:
|
||||
return jsonify({'error': 'User is not a member of this room'}), 404
|
||||
|
||||
permission.can_view = permissions.get('can_view', permission.can_view)
|
||||
permission.can_download = permissions.get('can_download', permission.can_download)
|
||||
permission.can_upload = permissions.get('can_upload', permission.can_upload)
|
||||
permission.can_delete = permissions.get('can_delete', permission.can_delete)
|
||||
permission.can_rename = permissions.get('can_rename', permission.can_rename)
|
||||
permission.can_move = permissions.get('can_move', permission.can_move)
|
||||
permission.can_share = permissions.get('can_share', permission.can_share)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
223
routes/rooms.py
Normal file
223
routes/rooms.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Room, User, RoomMemberPermission, RoomFile
|
||||
from forms import RoomForm
|
||||
from routes.room_files import user_has_permission
|
||||
|
||||
rooms_bp = Blueprint('rooms', __name__, url_prefix='/rooms')
|
||||
|
||||
@rooms_bp.route('/')
|
||||
@login_required
|
||||
def rooms():
|
||||
search = request.args.get('search', '').strip()
|
||||
if current_user.is_admin:
|
||||
query = Room.query
|
||||
else:
|
||||
query = Room.query.filter(Room.members.any(id=current_user.id))
|
||||
if search:
|
||||
query = query.filter(Room.name.ilike(f'%{search}%'))
|
||||
rooms = query.order_by(Room.created_at.desc()).all()
|
||||
return render_template('rooms.html', rooms=rooms, search=search)
|
||||
|
||||
@rooms_bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_room():
|
||||
form = RoomForm()
|
||||
if form.validate_on_submit():
|
||||
room = Room(
|
||||
name=form.name.data,
|
||||
description=form.description.data,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
# Add creator as a member with full permissions
|
||||
room.members.append(current_user)
|
||||
creator_permission = RoomMemberPermission(
|
||||
room=room,
|
||||
user=current_user,
|
||||
can_view=True,
|
||||
can_upload=True,
|
||||
can_delete=True,
|
||||
can_share=True
|
||||
)
|
||||
db.session.add(room)
|
||||
db.session.add(creator_permission)
|
||||
db.session.commit()
|
||||
|
||||
flash('Room created successfully!', 'success')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
return render_template('create_room.html', form=form)
|
||||
|
||||
@rooms_bp.route('/<int:room_id>')
|
||||
@login_required
|
||||
def room(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Admins always have access
|
||||
if not current_user.is_admin:
|
||||
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
||||
if not is_member:
|
||||
flash('You do not have access to this room.', 'error')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
can_download = user_has_permission(room, 'can_download')
|
||||
can_upload = user_has_permission(room, 'can_upload')
|
||||
can_delete = user_has_permission(room, 'can_delete')
|
||||
can_rename = user_has_permission(room, 'can_rename')
|
||||
can_move = user_has_permission(room, 'can_move')
|
||||
can_share = user_has_permission(room, 'can_share')
|
||||
return render_template('room.html', room=room, can_download=can_download, can_upload=can_upload, can_delete=can_delete, can_rename=can_rename, can_move=can_move, can_share=can_share)
|
||||
|
||||
@rooms_bp.route('/<int:room_id>/members')
|
||||
@login_required
|
||||
def room_members(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Admins always have access
|
||||
if not current_user.is_admin:
|
||||
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
||||
if not is_member:
|
||||
flash('You do not have access to this room.', 'error')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can manage room members.', 'error')
|
||||
return redirect(url_for('rooms.room', room_id=room_id))
|
||||
member_permissions = {p.user_id: p for p in room.member_permissions}
|
||||
available_users = User.query.filter(~User.id.in_(member_permissions.keys())).all()
|
||||
return render_template('room_members.html', room=room, available_users=available_users, member_permissions=member_permissions)
|
||||
|
||||
@rooms_bp.route('/<int:room_id>/members/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_member(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Membership check using RoomMemberPermission
|
||||
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
||||
if not is_member:
|
||||
flash('You do not have access to this room.', 'error')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can manage room members.', 'error')
|
||||
return redirect(url_for('rooms.room', room_id=room_id))
|
||||
user_id = request.form.get('user_id')
|
||||
if not user_id:
|
||||
flash('Please select a user to add.', 'error')
|
||||
return redirect(url_for('rooms.room_members', room_id=room_id))
|
||||
user = User.query.get_or_404(user_id)
|
||||
# Check if already a member
|
||||
if RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user.id).first():
|
||||
flash('User is already a member of this room.', 'error')
|
||||
else:
|
||||
perm = RoomMemberPermission(room_id=room_id, user_id=user.id, can_view=True)
|
||||
db.session.add(perm)
|
||||
# Ensure user is added to the room.members relationship
|
||||
if user not in room.members:
|
||||
room.members.append(user)
|
||||
db.session.commit()
|
||||
flash(f'{user.username} has been added to the room.', 'success')
|
||||
return redirect(url_for('rooms.room_members', room_id=room_id))
|
||||
|
||||
@rooms_bp.route('/<int:room_id>/members/<int:user_id>/remove', methods=['POST'])
|
||||
@login_required
|
||||
def remove_member(room_id, user_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Membership check using RoomMemberPermission
|
||||
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
|
||||
if not is_member:
|
||||
flash('You do not have access to this room.', 'error')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can manage room members.', 'error')
|
||||
return redirect(url_for('rooms.room', room_id=room_id))
|
||||
if user_id == room.created_by:
|
||||
flash('Cannot remove the room creator.', 'error')
|
||||
else:
|
||||
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
|
||||
if not perm:
|
||||
flash('User is not a member of this room.', 'error')
|
||||
else:
|
||||
db.session.delete(perm)
|
||||
db.session.commit()
|
||||
flash('User has been removed from the room.', 'success')
|
||||
return redirect(url_for('rooms.room_members', room_id=room_id))
|
||||
|
||||
@rooms_bp.route('/<int:room_id>/members/<int:user_id>/permissions', methods=['POST'])
|
||||
@login_required
|
||||
def update_member_permissions(room_id, user_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can update permissions.', 'error')
|
||||
return redirect(url_for('rooms.room_members', room_id=room_id))
|
||||
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
|
||||
if not perm:
|
||||
flash('Member not found.', 'error')
|
||||
return redirect(url_for('rooms.room_members', room_id=room_id))
|
||||
perm.can_view = bool(request.form.get('can_view'))
|
||||
perm.can_download = bool(request.form.get('can_download'))
|
||||
perm.can_upload = bool(request.form.get('can_upload'))
|
||||
perm.can_delete = bool(request.form.get('can_delete'))
|
||||
perm.can_rename = bool(request.form.get('can_rename'))
|
||||
perm.can_move = bool(request.form.get('can_move'))
|
||||
perm.can_share = bool(request.form.get('can_share'))
|
||||
db.session.commit()
|
||||
flash('Permissions updated.', 'success')
|
||||
return redirect(url_for('rooms.room_members', room_id=room_id))
|
||||
|
||||
@rooms_bp.route('/<int:room_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_room(room_id):
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can edit rooms.', 'error')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
|
||||
room = Room.query.get_or_404(room_id)
|
||||
form = RoomForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
room.name = form.name.data
|
||||
room.description = form.description.data
|
||||
db.session.commit()
|
||||
flash('Room updated successfully!', 'success')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
|
||||
# Pre-populate form with existing room data
|
||||
if request.method == 'GET':
|
||||
form.name.data = room.name
|
||||
form.description.data = room.description
|
||||
|
||||
return render_template('edit_room.html', form=form, room=room)
|
||||
|
||||
@rooms_bp.route('/<int:room_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_room(room_id):
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can delete rooms.', 'error')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
|
||||
room = Room.query.get_or_404(room_id)
|
||||
room_name = room.name
|
||||
|
||||
try:
|
||||
print(f"Attempting to delete room {room_id} ({room_name})")
|
||||
|
||||
# Delete the room (cascade will handle the rest)
|
||||
db.session.delete(room)
|
||||
db.session.commit()
|
||||
print("Room deleted successfully")
|
||||
|
||||
flash(f'Room "{room_name}" has been deleted.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('An error occurred while deleting the room. Please try again.', 'error')
|
||||
print(f"Error deleting room: {str(e)}")
|
||||
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
|
||||
@rooms_bp.route('/room/<int:room_id>/view/<path:file_path>')
|
||||
@login_required
|
||||
def view_file(room_id, file_path):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
# Check if user has access to the room
|
||||
if not current_user.is_admin and current_user not in room.members:
|
||||
flash('You do not have access to this room.', 'error')
|
||||
return redirect(url_for('rooms.rooms'))
|
||||
|
||||
file = RoomFile.query.filter_by(room_id=room_id, path=file_path).first_or_404()
|
||||
|
||||
# Continue with file viewing logic...
|
||||
110
routes/trash.py
Normal file
110
routes/trash.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from flask import Blueprint, jsonify, request, abort
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Room, RoomFile, TrashedFile
|
||||
from utils import user_has_permission, clean_path
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
trash_bp = Blueprint('trash', __name__)
|
||||
|
||||
@trash_bp.route('/<int:room_id>/trash', methods=['GET'])
|
||||
@login_required
|
||||
def list_trashed_files(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_view'):
|
||||
abort(403)
|
||||
|
||||
# Get all trashed files in the room
|
||||
files = TrashedFile.query.filter_by(room_id=room_id).order_by(TrashedFile.deleted_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for f in files:
|
||||
uploader_full_name = None
|
||||
uploader_profile_pic = None
|
||||
if f.uploader:
|
||||
uploader_full_name = f.uploader.username
|
||||
if getattr(f.uploader, 'last_name', None):
|
||||
uploader_full_name += ' ' + f.uploader.last_name
|
||||
uploader_profile_pic = f.uploader.profile_picture if getattr(f.uploader, 'profile_picture', None) else None
|
||||
|
||||
deleter_full_name = None
|
||||
if f.deleter:
|
||||
deleter_full_name = f.deleter.username
|
||||
if getattr(f.deleter, 'last_name', None):
|
||||
deleter_full_name += ' ' + f.deleter.last_name
|
||||
|
||||
result.append({
|
||||
'id': f.id,
|
||||
'name': f.name,
|
||||
'type': f.type,
|
||||
'size': f.size if f.type == 'file' else '-',
|
||||
'modified': f.modified,
|
||||
'uploaded_by': uploader_full_name,
|
||||
'uploader_profile_pic': uploader_profile_pic,
|
||||
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
|
||||
'original_path': f.original_path,
|
||||
'deleted_by': deleter_full_name,
|
||||
'deleted_at': f.deleted_at.isoformat()
|
||||
})
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@trash_bp.route('/<int:room_id>/trash/<int:trash_id>/restore', methods=['POST'])
|
||||
@login_required
|
||||
def restore_file(room_id, trash_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_upload'):
|
||||
abort(403)
|
||||
|
||||
trashed_file = TrashedFile.query.get_or_404(trash_id)
|
||||
if trashed_file.room_id != room_id:
|
||||
abort(404)
|
||||
|
||||
# Create new RoomFile entry
|
||||
rf = RoomFile(
|
||||
room_id=room_id,
|
||||
name=trashed_file.name,
|
||||
path=trashed_file.original_path,
|
||||
type=trashed_file.type,
|
||||
size=trashed_file.size,
|
||||
modified=trashed_file.modified,
|
||||
uploaded_by=trashed_file.uploaded_by,
|
||||
uploaded_at=trashed_file.uploaded_at
|
||||
)
|
||||
db.session.add(rf)
|
||||
|
||||
# Delete the trashed file entry
|
||||
db.session.delete(trashed_file)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
@trash_bp.route('/<int:room_id>/trash/<int:trash_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def permanently_delete_file(room_id, trash_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_delete'):
|
||||
abort(403)
|
||||
|
||||
trashed_file = TrashedFile.query.get_or_404(trash_id)
|
||||
if trashed_file.room_id != room_id:
|
||||
abort(404)
|
||||
|
||||
# Delete the trashed file entry
|
||||
db.session.delete(trashed_file)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
@trash_bp.route('/<int:room_id>/trash/empty', methods=['POST'])
|
||||
@login_required
|
||||
def empty_trash(room_id):
|
||||
room = Room.query.get_or_404(room_id)
|
||||
if not user_has_permission(room, 'can_delete'):
|
||||
abort(403)
|
||||
|
||||
# Delete all trashed files for this room
|
||||
TrashedFile.query.filter_by(room_id=room_id).delete()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
22
routes/user.py
Normal file
22
routes/user.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_login import login_required, current_user
|
||||
from models import db
|
||||
|
||||
user_bp = Blueprint('user', __name__, url_prefix='/api/user')
|
||||
|
||||
@user_bp.route('/preferred_view', methods=['GET'])
|
||||
@login_required
|
||||
def get_preferred_view():
|
||||
return jsonify({'preferred_view': current_user.preferred_view})
|
||||
|
||||
@user_bp.route('/preferred_view', methods=['POST'])
|
||||
@login_required
|
||||
def update_preferred_view():
|
||||
data = request.get_json()
|
||||
if not data or 'preferred_view' not in data:
|
||||
return jsonify({'error': 'Missing preferred_view'}), 400
|
||||
if data['preferred_view'] not in ['grid', 'list']:
|
||||
return jsonify({'error': 'Invalid preferred_view'}), 400
|
||||
current_user.preferred_view = data['preferred_view']
|
||||
db.session.commit()
|
||||
return jsonify({'preferred_view': current_user.preferred_view})
|
||||
BIN
static/default-avatar.png
Normal file
BIN
static/default-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
58
sync_room_files.py
Normal file
58
sync_room_files.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from app import create_app
|
||||
from models import db, Room, RoomFile, User
|
||||
|
||||
DATA_ROOT = '/data/rooms'
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
admin_user = User.query.filter_by(is_admin=True).first()
|
||||
if not admin_user:
|
||||
print('No admin user found. Aborting.')
|
||||
exit(1)
|
||||
rooms = Room.query.all()
|
||||
for room in rooms:
|
||||
room_dir = os.path.join(DATA_ROOT, str(room.id))
|
||||
if not os.path.exists(room_dir):
|
||||
continue
|
||||
for root, dirs, files in os.walk(room_dir):
|
||||
rel_root = os.path.relpath(root, room_dir)
|
||||
rel_path = '' if rel_root == '.' else rel_root.replace('\\', '/')
|
||||
# Folders
|
||||
for d in dirs:
|
||||
exists = RoomFile.query.filter_by(room_id=room.id, name=d, path=rel_path, type='folder').first()
|
||||
folder_path = os.path.join(root, d)
|
||||
stat = os.stat(folder_path)
|
||||
if not exists:
|
||||
rf = RoomFile(
|
||||
room_id=room.id,
|
||||
name=d,
|
||||
path=rel_path,
|
||||
type='folder',
|
||||
size=None,
|
||||
modified=stat.st_mtime,
|
||||
uploaded_by=admin_user.id,
|
||||
uploaded_at=datetime.utcfromtimestamp(stat.st_mtime)
|
||||
)
|
||||
db.session.add(rf)
|
||||
# Files
|
||||
for f in files:
|
||||
exists = RoomFile.query.filter_by(room_id=room.id, name=f, path=rel_path, type='file').first()
|
||||
file_path = os.path.join(root, f)
|
||||
stat = os.stat(file_path)
|
||||
if not exists:
|
||||
rf = RoomFile(
|
||||
room_id=room.id,
|
||||
name=f,
|
||||
path=rel_path,
|
||||
type='file',
|
||||
size=stat.st_size,
|
||||
modified=stat.st_mtime,
|
||||
uploaded_by=admin_user.id,
|
||||
uploaded_at=datetime.utcfromtimestamp(stat.st_mtime)
|
||||
)
|
||||
db.session.add(rf)
|
||||
db.session.commit()
|
||||
print('RoomFile table synchronized with filesystem.')
|
||||
39
tasks.py
Normal file
39
tasks.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime, timedelta
|
||||
from models import db, RoomFile
|
||||
import os
|
||||
|
||||
def cleanup_trash():
|
||||
"""
|
||||
Permanently deletes files that have been in trash for more than 30 days.
|
||||
This function should be called by a scheduler (e.g., cron job) daily.
|
||||
"""
|
||||
# Calculate the cutoff date (30 days ago)
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=30)
|
||||
|
||||
# Find all files that were deleted before the cutoff date
|
||||
files_to_delete = RoomFile.query.filter(
|
||||
RoomFile.deleted == True,
|
||||
RoomFile.deleted_at <= cutoff_date
|
||||
).all()
|
||||
|
||||
for file in files_to_delete:
|
||||
try:
|
||||
# Delete the file from storage if it's a file
|
||||
if file.type == 'file':
|
||||
file_path = os.path.join('/data/rooms', str(file.room_id), file.path, file.name)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
# Delete the database record
|
||||
db.session.delete(file)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error deleting file {file.name}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Commit all changes
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
print(f"Error committing changes: {str(e)}")
|
||||
db.session.rollback()
|
||||
164
templates/base.html
Normal file
164
templates/base.html
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}DocuPulse{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #16767b;
|
||||
--secondary-color: #741b5f;
|
||||
--primary-light: #1a8a90;
|
||||
--secondary-light: #8a2170;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: white;
|
||||
min-height: calc(100vh - 56px);
|
||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: #333;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar .nav-link i {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-light);
|
||||
border-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.document-card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.document-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">DocuPulse</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link flex items-center justify-center" href="#">
|
||||
<i class="fas fa-bell text-xl" style="width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center;"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle flex items-center gap-2" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<img src="{{ url_for('profile_pic', filename=current_user.profile_picture) if current_user.profile_picture else url_for('static', filename='default-avatar.png') }}"
|
||||
alt="Profile Picture"
|
||||
class="w-8 h-8 rounded-full object-cover border-2 border-white shadow"
|
||||
style="display: inline-block; vertical-align: middle;">
|
||||
<span class="text-white font-medium">{{ current_user.username }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{{ url_for('main.profile') }}"><i class="fas fa-user"></i> Profile</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-cog"></i> Settings</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Sidebar -->
|
||||
<div class="col-md-3 col-lg-2 px-0 sidebar">
|
||||
<div class="p-3">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="fas fa-home"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'rooms.rooms' %}active{% endif %}" href="{{ url_for('rooms.rooms') }}">
|
||||
<i class="fas fa-door-open"></i> Rooms
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{% if current_user.is_admin %}
|
||||
<a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}">
|
||||
<i class="fas fa-address-book"></i> Contacts
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'main.starred' %}active{% endif %}" href="{{ url_for('main.starred') }}">
|
||||
<i class="fas fa-star"></i> Starred
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'main.trash' %}active{% endif %}" href="{{ url_for('main.trash') }}">
|
||||
<i class="fas fa-trash"></i> Trash
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="col-md-9 col-lg-10 main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
8
templates/contacts.html
Normal file
8
templates/contacts.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Contacts - DocuPulse{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Contacts</h2>
|
||||
<p class="text-muted">Your contacts will appear here.</p>
|
||||
{% endblock %}
|
||||
196
templates/contacts/form.html
Normal file
196
templates/contacts/form.html
Normal file
@@ -0,0 +1,196 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
{% if title %}
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6">{{ title }}</h1>
|
||||
{% else %}
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6">User Form</h1>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('contacts.contacts_list') }}"
|
||||
class="text-gray-600 hover:text-gray-900">
|
||||
← Back to Contacts
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<form method="POST" class="space-y-6" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<!-- Profile Picture Upload (matches profile page style) -->
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div class="relative group flex flex-col items-center">
|
||||
<label for="profile_picture" class="cursor-pointer">
|
||||
<img id="avatarPreview" src="{{ url_for('profile_pic', filename=form.profile_picture.data or user.profile_picture) if (form.profile_picture.data or (user and user.profile_picture)) else url_for('static', filename='default-avatar.png') }}" alt="Profile Picture" class="w-32 h-32 rounded-full object-cover border-4 border-gray-200 mb-0 transition duration-200 group-hover:opacity-80 group-hover:ring-4 group-hover:ring-primary-200 shadow-sm">
|
||||
<input id="profile_picture" type="file" name="profile_picture" accept="image/*" class="hidden" onchange="previewAvatar(event)" />
|
||||
</label>
|
||||
{% if user and user.profile_picture %}
|
||||
<button type="submit" name="remove_picture" value="1" class="mt-2 mb-2 text-xs px-3 py-1 rounded bg-red-100 text-red-700 border border-red-200 hover:bg-red-200 transition">Remove Picture</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
{{ form.first_name.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.first_name(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% if form.first_name.errors %}
|
||||
{% for error in form.first_name.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form.last_name.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.last_name(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% if form.last_name.errors %}
|
||||
{% for error in form.last_name.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form.email.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.email(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% if form.email.errors %}
|
||||
{% for error in form.email.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<div class="relative">
|
||||
{{ form.new_password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.new_password(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 pr-10", autocomplete="new-password", id="new_password") }}
|
||||
<button type="button" tabindex="-1" class="absolute right-2 top-9 text-gray-500" style="background: none; border: none;" onmousedown="showPwd('new_password')" onmouseup="hidePwd('new_password')" onmouseleave="hidePwd('new_password')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative">
|
||||
{{ form.confirm_password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.confirm_password(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 pr-10", autocomplete="new-password", id="confirm_password") }}
|
||||
<button type="button" tabindex="-1" class="absolute right-2 top-9 text-gray-500" style="background: none; border: none;" onmousedown="showPwd('confirm_password')" onmouseup="hidePwd('confirm_password')" onmouseleave="hidePwd('confirm_password')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
{% if form.confirm_password.errors %}
|
||||
{% for error in form.confirm_password.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
function showPwd(id) {
|
||||
document.getElementById(id).type = 'text';
|
||||
}
|
||||
function hidePwd(id) {
|
||||
document.getElementById(id).type = 'password';
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
{{ form.phone.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.phone(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% if form.phone.errors %}
|
||||
{% for error in form.phone.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form.company.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.company(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% if form.company.errors %}
|
||||
{% for error in form.company.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form.position.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.position(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% if form.position.errors %}
|
||||
{% for error in form.position.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form.notes.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.notes(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500", rows="4") }}
|
||||
{% if form.notes.errors %}
|
||||
{% for error in form.notes.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
{{ form.is_active(class="h-4 w-4 focus:ring-blue-500 border-gray-300 rounded", style="accent-color: #16767b;") }}
|
||||
{{ form.is_active.label(class="ml-2 block text-sm text-gray-900") }}
|
||||
</div>
|
||||
<div class="flex items-center relative group">
|
||||
{% set is_last_admin = current_user.is_admin and total_admins <= 1 %}
|
||||
{{ form.is_admin(
|
||||
class="h-4 w-4 focus:ring-blue-500 border-gray-300 rounded",
|
||||
style="accent-color: #16767b;",
|
||||
disabled=is_last_admin and form.is_admin.data
|
||||
) }}
|
||||
{{ form.is_admin.label(class="ml-2 block text-sm text-gray-900") }}
|
||||
{% if is_last_admin and form.is_admin.data %}
|
||||
<input type="hidden" name="is_admin" value="y">
|
||||
{% endif %}
|
||||
<div class="absolute left-0 bottom-full mb-2 hidden group-hover:block bg-gray-800 text-white text-xs rounded py-1 px-2 w-48">
|
||||
Admin users have full access to manage contacts and system settings.
|
||||
</div>
|
||||
{% if is_last_admin and form.is_admin.data %}
|
||||
<div class="ml-2 text-sm text-amber-600">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
You are the only admin
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.is_admin.errors %}
|
||||
<div class="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
{% for error in form.is_admin.errors %}
|
||||
<p class="text-sm text-red-700">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
{{ form.submit(class="text-white px-6 py-2 rounded-lg transition duration-200", style="background-color: #16767b; border: 1px solid #16767b;", onmouseover="this.style.backgroundColor='#1a8a90'", onmouseout="this.style.backgroundColor='#16767b'") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function previewAvatar(event) {
|
||||
const [file] = event.target.files;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('avatarPreview').src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
247
templates/contacts/list.html
Normal file
247
templates/contacts/list.html
Normal file
@@ -0,0 +1,247 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
body {
|
||||
background: #f7f9fb;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">Contacts</h1>
|
||||
<a href="{{ url_for('contacts.new_contact') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-white text-sm font-semibold transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg no-underline"
|
||||
style="background-color: #16767b; border: 1px solid #16767b;"
|
||||
onmouseover="this.style.backgroundColor='#1a8a90'"
|
||||
onmouseout="this.style.backgroundColor='#16767b'">
|
||||
<i class="fas fa-plus"></i>
|
||||
Add New Contact
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Section -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<form method="GET" class="flex flex-col md:flex-row gap-4" id="filterForm">
|
||||
<div class="flex-1">
|
||||
<input type="text" name="search" placeholder="Search contacts..."
|
||||
value="{{ request.args.get('search', '') }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||||
</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<select name="status" class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||||
<option value="">All Status</option>
|
||||
<option value="active" {% if request.args.get('status') == 'active' %}selected{% endif %}>Active</option>
|
||||
<option value="inactive" {% if request.args.get('status') == 'inactive' %}selected{% endif %}>Inactive</option>
|
||||
</select>
|
||||
<select name="role" class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin" {% if request.args.get('role') == 'admin' %}selected{% endif %}>Admin</option>
|
||||
<option value="user" {% if request.args.get('role') == 'user' %}selected{% endif %}>User</option>
|
||||
</select>
|
||||
<button type="button" id="clearFilters" class="px-4 py-2 rounded-lg text-white font-medium transition-colors duration-200"
|
||||
style="background-color: #16767b; border: 1px solid #16767b;"
|
||||
onmouseover="this.style.backgroundColor='#1a8a90'"
|
||||
onmouseout="this.style.backgroundColor='#16767b'">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
// Debounce function
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
// Auto-submit the form on select change
|
||||
document.querySelectorAll('#filterForm select').forEach(function(el) {
|
||||
el.addEventListener('change', function() {
|
||||
document.getElementById('filterForm').submit();
|
||||
});
|
||||
});
|
||||
// Debounced submit for search input, keep cursor after reload
|
||||
const searchInput = document.querySelector('#filterForm input[name="search"]');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', debounce(function() {
|
||||
// Save value and cursor position
|
||||
sessionStorage.setItem('searchFocus', '1');
|
||||
sessionStorage.setItem('searchValue', searchInput.value);
|
||||
sessionStorage.setItem('searchPos', searchInput.selectionStart);
|
||||
document.getElementById('filterForm').submit();
|
||||
}, 300));
|
||||
// On page load, restore focus and cursor position if needed
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
if (sessionStorage.getItem('searchFocus') === '1') {
|
||||
searchInput.focus();
|
||||
const val = sessionStorage.getItem('searchValue') || '';
|
||||
const pos = parseInt(sessionStorage.getItem('searchPos')) || val.length;
|
||||
searchInput.value = val;
|
||||
searchInput.setSelectionRange(pos, pos);
|
||||
// Clean up
|
||||
sessionStorage.removeItem('searchFocus');
|
||||
sessionStorage.removeItem('searchValue');
|
||||
sessionStorage.removeItem('searchPos');
|
||||
}
|
||||
});
|
||||
}
|
||||
// Clear button resets all filters and submits the form
|
||||
document.getElementById('clearFilters').addEventListener('click', function() {
|
||||
document.querySelector('#filterForm input[name="search"]').value = '';
|
||||
document.querySelector('#filterForm select[name="status"]').selectedIndex = 0;
|
||||
document.querySelector('#filterForm select[name="role"]').selectedIndex = 0;
|
||||
document.getElementById('filterForm').submit();
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Contacts List -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contact Info</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Company</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for user in users %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10">
|
||||
<img class="h-10 w-10 rounded-full object-cover"
|
||||
src="{{ url_for('profile_pic', filename=user.profile_picture) if user.profile_picture else url_for('static', filename='default-avatar.png') }}"
|
||||
alt="{{ user.username }}">
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{ user.username }} {{ user.last_name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ user.position or 'No position' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex flex-row flex-wrap gap-1.5">
|
||||
<a href="mailto:{{ user.email }}"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200"
|
||||
style="background-color: rgba(22,118,123,0.08); color: #16767b;">
|
||||
<i class="fas fa-envelope" style="font-size: 0.85em; opacity: 0.7;"></i>
|
||||
{{ user.email }}
|
||||
</a>
|
||||
{% if user.phone %}
|
||||
<a href="tel:{{ user.phone }}"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200"
|
||||
style="background-color: rgba(22,118,123,0.08); color: #16767b;">
|
||||
<i class="fas fa-phone" style="font-size: 0.85em; opacity: 0.7;"></i>
|
||||
{{ user.phone }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900">{{ user.company or 'No company' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex flex-row flex-wrap gap-1.5">
|
||||
{% if user.email != current_user.email and not user.is_admin %}
|
||||
<form method="POST" action="{{ url_for('contacts.toggle_active', id=user.id) }}" class="inline">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium transition-colors duration-200 cursor-pointer"
|
||||
style="background-color: {% if user.is_active %}rgba(34,197,94,0.1){% else %}rgba(239,68,68,0.1){% endif %}; color: {% if user.is_active %}#15803d{% else %}#b91c1c{% endif %};">
|
||||
<i class="fas fa-{% if user.is_active %}check-circle{% else %}times-circle{% endif %}" style="font-size: 0.85em; opacity: 0.7;"></i>
|
||||
{{ 'Active' if user.is_active else 'Inactive' }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium"
|
||||
style="background-color: {% if user.is_active %}rgba(34,197,94,0.1){% else %}rgba(239,68,68,0.1){% endif %}; color: {% if user.is_active %}#15803d{% else %}#b91c1c{% endif %};">
|
||||
<i class="fas fa-{% if user.is_active %}check-circle{% else %}times-circle{% endif %}" style="font-size: 0.85em; opacity: 0.7;"></i>
|
||||
{{ 'Active' if user.is_active else 'Inactive' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex flex-row flex-wrap gap-1.5">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium"
|
||||
style="background-color: {% if user.is_admin %}rgba(147,51,234,0.1){% else %}rgba(107,114,128,0.1){% endif %}; color: {% if user.is_admin %}#7e22ce{% else %}#374151{% endif %};">
|
||||
<i class="fas fa-{% if user.is_admin %}shield-alt{% else %}user{% endif %}" style="font-size: 0.85em; opacity: 0.7;"></i>
|
||||
{{ 'Admin' if user.is_admin else 'User' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end gap-1.5">
|
||||
<a href="{{ url_for('contacts.edit_contact', id=user.id) }}"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200"
|
||||
style="background-color: rgba(22,118,123,0.08); color: #16767b;">
|
||||
<i class="fas fa-edit" style="font-size: 0.85em; opacity: 0.7;"></i>
|
||||
Edit
|
||||
</a>
|
||||
{% if user.email != current_user.email %}
|
||||
<form method="POST" action="{{ url_for('contacts.delete_contact', id=user.id) }}" class="inline"
|
||||
onsubmit="return confirm('Are you sure you want to delete this contact?');">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200"
|
||||
style="background-color: rgba(239,68,68,0.1); color: #b91c1c;">
|
||||
<i class="fas fa-trash" style="font-size: 0.85em; opacity: 0.7;"></i>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination and pagination.pages > 1 %}
|
||||
<div class="mt-6 flex justify-center">
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('contacts.contacts_list', page=pagination.prev_num, **request.args) }}"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page in pagination.iter_pages() %}
|
||||
{% if page %}
|
||||
<a href="{{ url_for('contacts.contacts_list', page=page, **request.args) }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium {% if page == pagination.page %}text-[#16767b] bg-[#16767b]/10{% else %}text-gray-700 hover:bg-gray-50{% endif %}">
|
||||
{{ page }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
|
||||
...
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('contacts.contacts_list', page=pagination.next_num, **request.args) }}"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
48
templates/create_room.html
Normal file
48
templates/create_room.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Room - DocuPulse{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title mb-0">Create New Room</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('rooms.create_room') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
{{ form.name.label(class="form-label") }}
|
||||
{{ form.name(class="form-control") }}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.name.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.description.id }}" class="form-label">Description (Optional)</label>
|
||||
{{ form.description(class="form-control", rows="3", placeholder="Enter a description (optional)") }}
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.description.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('rooms.rooms') }}" class="btn btn-secondary">Cancel</a>
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
471
templates/dashboard.html
Normal file
471
templates/dashboard.html
Normal file
@@ -0,0 +1,471 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - DocuPulse{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Welcome back, {{ current_user.username }}!</h2>
|
||||
<div class="input-group" style="max-width: 300px;">
|
||||
<input type="text" class="form-control" placeholder="Search documents...">
|
||||
<button class="btn btn-outline-secondary" type="button">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% macro format_size(size) %}
|
||||
{% if size < 1024 %}
|
||||
{{ size }} B
|
||||
{% elif size < 1024 * 1024 %}
|
||||
{{ (size / 1024)|round(1) }} KB
|
||||
{% elif size < 1024 * 1024 * 1024 %}
|
||||
{{ (size / (1024 * 1024))|round(1) }} MB
|
||||
{% else %}
|
||||
{{ (size / (1024 * 1024 * 1024))|round(1) }} GB
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
<style>
|
||||
.masonry {
|
||||
column-count: 1;
|
||||
column-gap: 1.5rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.masonry { column-count: 2; }
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.masonry { column-count: 3; }
|
||||
}
|
||||
.masonry-card {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="masonry">
|
||||
<div class="masonry-card">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-database me-2" style="color:#16767b;"></i>Storage Overview</h5>
|
||||
<a href="{{ url_for('rooms.rooms') }}" class="btn btn-sm text-white" style="background-color:#16767b;">Browse</a>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-door-open me-2" style="color:#16767b;"></i>
|
||||
<span class="text-muted">Rooms:</span>
|
||||
</div>
|
||||
<div class="fw-bold" style="color:#16767b;">{{ room_count }}</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-file me-2" style="color:#16767b;"></i>
|
||||
<span class="text-muted">Files:</span>
|
||||
</div>
|
||||
<div class="fw-bold" style="color:#16767b;">{{ file_count }}</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-folder me-2" style="color:#16767b;"></i>
|
||||
<span class="text-muted">Folders:</span>
|
||||
</div>
|
||||
<div class="fw-bold" style="color:#16767b;">{{ folder_count }}</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-hdd me-2" style="color:#16767b;"></i>
|
||||
<span class="text-muted">Total Size:</span>
|
||||
</div>
|
||||
<div class="fw-bold" style="color:#16767b;">{{ format_size(total_size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="masonry-card">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-white border-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-history me-2" style="color:#16767b;"></i>Recent Activity</h5>
|
||||
<a href="{{ url_for('rooms.rooms') }}" class="btn btn-sm text-white" style="background-color:#16767b;">View All</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush">
|
||||
{% for activity in recent_activity %}
|
||||
<div class="list-group-item px-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
{% if activity.type == 'folder' %}
|
||||
<i class="fas fa-folder me-2" style="color:#16767b;"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-file me-2" style="color:#16767b;"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<h6 class="mb-0">{{ activity.name }}</h6>
|
||||
{% if activity.is_starred %}
|
||||
<span class="badge bg-warning text-dark ms-2" style="background-color: rgba(255,215,0,0.15) !important; color: #ffd700 !important;">
|
||||
<i class="fas fa-star me-1"></i>Starred
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if activity.is_deleted %}
|
||||
<span class="badge bg-danger ms-2" style="background-color: rgba(220,53,69,0.15) !important; color: #dc3545 !important;">
|
||||
<i class="fas fa-trash me-1"></i>Trash
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{ activity.room.name }} •
|
||||
{{ activity.uploader.username }} {{ activity.uploader.last_name }} •
|
||||
{% if activity.uploaded_at %}{{ activity.uploaded_at|timeago }}{% else %}Unknown{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{% if activity.type == 'file' and activity.can_download %}
|
||||
<a href="{{ url_for('room_files.download_room_file', room_id=activity.room.id, filename=activity.name, path=activity.path) }}"
|
||||
class="btn btn-sm text-white ms-2" style="background-color:#16767b; border-radius:6px; border:none; box-shadow:0 1px 2px rgba(22,118,123,0.08);" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="masonry-card">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-chart-pie me-2" style="color:#16767b;"></i>Storage Usage</h5>
|
||||
<a href="{{ url_for('rooms.rooms') }}" class="btn btn-sm text-white" style="background-color:#16767b;">View Details</a>
|
||||
</div>
|
||||
{% if storage_by_type %}
|
||||
<div class="position-relative" style="height: 200px;">
|
||||
<canvas id="storageChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
{% for type in storage_by_type %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-file me-2" style="color:#16767b;"></i>
|
||||
<span class="text-muted">{{ type.extension|upper }}:</span>
|
||||
</div>
|
||||
<div class="fw-bold" style="color:#16767b;">{{ format_size(type.total_size) }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted small">No storage data available</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<div class="masonry-card">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-address-book me-2" style="color:#16767b;"></i>Recent Contacts</h5>
|
||||
<div>
|
||||
<a href="{{ url_for('contacts.contacts_list') }}" class="btn btn-sm text-white me-2" style="background-color:#16767b;">View All</a>
|
||||
<a href="{{ url_for('contacts.new_contact') }}" class="btn btn-sm text-white" style="background-color:#16767b;">+ Add</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if recent_contacts %}
|
||||
<ul class="list-unstyled mb-3">
|
||||
{% for contact in recent_contacts %}
|
||||
<li class="mb-2">
|
||||
<div class="fw-semibold">{{ contact.first_name }} {{ contact.last_name }}</div>
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<a href="mailto:{{ contact.email }}"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-sm font-normal transition duration-200 no-underline"
|
||||
style="background-color: rgba(22,118,123,0.08); color: #16767b;">
|
||||
<i class="fas fa-envelope mr-1" style="font-size: 0.85em; opacity: 0.7;"></i>{{ contact.email }}
|
||||
</a>
|
||||
{% if contact.phone %}
|
||||
<a href="tel:{{ contact.phone }}"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-sm font-normal transition duration-200 no-underline"
|
||||
style="background-color: rgba(22,118,123,0.08); color: #16767b;">
|
||||
<i class="fas fa-phone mr-1" style="font-size: 0.85em; opacity: 0.7;"></i>{{ contact.phone }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="text-muted small">No contacts yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="masonry-card">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-chart-pie me-2" style="color:#16767b;"></i>Contact Status</h5>
|
||||
<div>
|
||||
<a href="{{ url_for('contacts.contacts_list') }}" class="btn btn-sm text-white me-2" style="background-color:#16767b;">View All</a>
|
||||
<a href="{{ url_for('contacts.new_contact') }}" class="btn btn-sm text-white" style="background-color:#16767b;">+ Add</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="position-relative" style="height: 200px;">
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center gap-4 mt-3">
|
||||
<div class="text-center">
|
||||
<div class="fw-bold" style="color:#16767b; font-size:1.5rem;">{{ active_count }}</div>
|
||||
<div class="text-muted small">Active</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="fw-bold" style="color:#741b5f; font-size:1.5rem;">{{ inactive_count }}</div>
|
||||
<div class="text-muted small">Inactive</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="masonry-card">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-star me-2" style="color:#16767b;"></i>Starred Files</h5>
|
||||
<a href="{{ url_for('main.starred') }}" class="btn btn-sm text-white" style="background-color:#16767b;">View All</a>
|
||||
</div>
|
||||
<div class="position-relative" style="height: 200px;">
|
||||
<canvas id="starredChart"></canvas>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center gap-4 mt-3">
|
||||
<div class="text-center">
|
||||
<div class="fw-bold" style="color:#ffd700; font-size:1.5rem;">{{ starred_count }}</div>
|
||||
<div class="text-muted small">Starred</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="fw-bold" style="color:#16767b; font-size:1.5rem;">{{ file_count - starred_count }}</div>
|
||||
<div class="text-muted small">Unstarred</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="masonry-card">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-trash me-2" style="color:#16767b;"></i>Trash</h5>
|
||||
<a href="{{ url_for('main.trash') }}" class="btn btn-sm text-white" style="background-color:#16767b;">View All</a>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-file me-2" style="color:#16767b;"></i>
|
||||
<span class="text-muted">Files in trash:</span>
|
||||
</div>
|
||||
<div class="fw-bold" style="color:#16767b;">{{ trash_count }}</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-exclamation-triangle me-2" style="color:#dc3545;"></i>
|
||||
<span class="text-muted">Deleting in 7 days:</span>
|
||||
</div>
|
||||
<div class="fw-bold" style="color:#dc3545;">{{ pending_deletion }}</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-clock me-2" style="color:#16767b;"></i>
|
||||
<span class="text-muted">Oldest deletion:</span>
|
||||
</div>
|
||||
<div class="fw-bold" style="color:#16767b;">{{ oldest_trash_date|default('N/A') }}</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-hdd me-2" style="color:#16767b;"></i>
|
||||
<span class="text-muted">Storage used:</span>
|
||||
</div>
|
||||
<div class="fw-bold" style="color:#16767b;">{{ format_size(trash_size) }}</div>
|
||||
</div>
|
||||
<div class="alert alert-info mb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
<div>
|
||||
<div class="fw-bold mb-1">Files will be permanently deleted after 30 days</div>
|
||||
<div class="small">You can restore files before they are permanently deleted</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="masonry-card">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-file-alt me-2" style="color:#16767b;"></i>Trash by Type</h5>
|
||||
<a href="{{ url_for('main.trash') }}" class="btn btn-sm text-white" style="background-color:#16767b;">View All</a>
|
||||
</div>
|
||||
{% if trash_by_type %}
|
||||
<div class="position-relative" style="height: 200px;">
|
||||
<canvas id="trashTypeChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
{% for type in trash_by_type %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-file me-2" style="color:#16767b;"></i>
|
||||
<span class="text-muted">{{ type.extension|upper }}:</span>
|
||||
</div>
|
||||
<div class="fw-bold" style="color:#16767b;">{{ type.count }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted small">No files in trash</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Contact Status Chart
|
||||
const statusCtx = document.getElementById('statusChart');
|
||||
if (statusCtx) {
|
||||
new Chart(statusCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Active', 'Inactive'],
|
||||
datasets: [{
|
||||
data: [{{ active_count }}, {{ inactive_count }}],
|
||||
backgroundColor: ['#16767b', '#741b5f'],
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
cutout: '70%'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Starred Files Chart
|
||||
const starredCtx = document.getElementById('starredChart');
|
||||
if (starredCtx) {
|
||||
new Chart(starredCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Starred', 'Unstarred'],
|
||||
datasets: [{
|
||||
data: [{{ starred_count }}, {{ file_count - starred_count }}],
|
||||
backgroundColor: ['#ffd700', '#16767b'],
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
cutout: '70%'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Storage Usage Chart
|
||||
const storageCtx = document.getElementById('storageChart');
|
||||
if (storageCtx) {
|
||||
const storageData = {
|
||||
labels: [{% for type in storage_by_type %}'{{ type.extension|upper }}'{% if not loop.last %}, {% endif %}{% endfor %}],
|
||||
datasets: [{
|
||||
data: [{% for type in storage_by_type %}{{ type.total_size }}{% if not loop.last %}, {% endif %}{% endfor %}],
|
||||
backgroundColor: [
|
||||
'#16767b', '#2c9da9', '#43c4d3', '#5ad9e8', '#71eefd',
|
||||
'#741b5f', '#8a2b73', '#a03b87', '#b64b9b', '#cc5baf'
|
||||
],
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4
|
||||
}]
|
||||
};
|
||||
|
||||
new Chart(storageCtx, {
|
||||
type: 'doughnut',
|
||||
data: storageData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
cutout: '70%'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Trash Type Chart
|
||||
const trashTypeCtx = document.getElementById('trashTypeChart');
|
||||
if (trashTypeCtx) {
|
||||
const trashTypeData = {
|
||||
labels: [{% for type in trash_by_type %}'{{ type.extension|upper }}'{% if not loop.last %}, {% endif %}{% endfor %}],
|
||||
datasets: [{
|
||||
data: [{% for type in trash_by_type %}{{ type.count }}{% if not loop.last %}, {% endif %}{% endfor %}],
|
||||
backgroundColor: [
|
||||
'#16767b', '#2c9da9', '#43c4d3', '#5ad9e8', '#71eefd',
|
||||
'#741b5f', '#8a2b73', '#a03b87', '#b64b9b', '#cc5baf'
|
||||
],
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4
|
||||
}]
|
||||
};
|
||||
|
||||
new Chart(trashTypeCtx, {
|
||||
type: 'doughnut',
|
||||
data: trashTypeData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
cutout: '70%'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
48
templates/edit_room.html
Normal file
48
templates/edit_room.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Room - {{ room.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title mb-0">Edit Room</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('rooms.edit_room', room_id=room.id) }}">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
{{ form.name.label(class="form-label") }}
|
||||
{{ form.name(class="form-control") }}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.name.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.description.id }}" class="form-label">Description (Optional)</label>
|
||||
{{ form.description(class="form-control", rows="3", placeholder="Enter a description (optional)") }}
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.description.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('rooms.rooms') }}" class="btn btn-secondary">Cancel</a>
|
||||
{{ form.submit(class="btn btn-primary", value="Update Room") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
148
templates/home.html
Normal file
148
templates/home.html
Normal file
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocuPulse - Legal Document Management</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #16767b;
|
||||
--secondary-color: #741b5f;
|
||||
--primary-light: #1a8a90;
|
||||
--secondary-light: #8a2170;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
transition: transform 0.3s;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2.5rem;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-light);
|
||||
border-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--secondary-color) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">DocuPulse</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.register') }}">Register</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero-section">
|
||||
<div class="container text-center">
|
||||
<h1 class="display-4 mb-4">Streamline Your Legal Document Management</h1>
|
||||
<p class="lead mb-5">Secure, efficient, and intelligent document handling for modern law practices</p>
|
||||
<a href="{{ url_for('auth.register') }}" class="btn btn-light btn-lg">Get Started</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<h2 class="text-center mb-5">Key Features</h2>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card feature-card h-100 p-4">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-shield-alt feature-icon"></i>
|
||||
<h3 class="h5">Secure Storage</h3>
|
||||
<p class="text-muted">Bank-level encryption and secure cloud storage for your sensitive legal documents</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card feature-card h-100 p-4">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-search feature-icon"></i>
|
||||
<h3 class="h5">Smart Search</h3>
|
||||
<p class="text-muted">Advanced search capabilities to find any document in seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card feature-card h-100 p-4">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-users feature-icon"></i>
|
||||
<h3 class="h5">Collaboration</h3>
|
||||
<p class="text-muted">Seamless collaboration tools for legal teams and clients</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="text-light py-4">
|
||||
<div class="container text-center">
|
||||
<p class="mb-0">© 2024 DocuPulse. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
templates/index.html
Normal file
12
templates/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Flask App</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to Flask App</h1>
|
||||
<p>Your application is running successfully!</p>
|
||||
</body>
|
||||
</html>
|
||||
105
templates/login.html
Normal file
105
templates/login.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - DocuPulse</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #16767b;
|
||||
--secondary-color: #741b5f;
|
||||
--primary-light: #1a8a90;
|
||||
--secondary-light: #8a2170;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
max-width: 400px;
|
||||
margin: 100px auto;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-light);
|
||||
border-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(22, 118, 123, 0.25);
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="login-container">
|
||||
<div class="card login-card">
|
||||
<div class="login-header text-center">
|
||||
<h2>Welcome Back</h2>
|
||||
<p class="mb-0">Sign in to your account</p>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
{% if category in ['error', 'danger', 'login'] %}
|
||||
<div class="alert alert-{{ 'danger' if category in ['error', 'danger'] else 'info' }}">{{ message }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="remember" name="remember">
|
||||
<label class="form-check-label" for="remember">Remember me</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<p class="mb-0">Don't have an account? <a href="{{ url_for('auth.register') }}" class="text-decoration-none">Register</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
115
templates/profile.html
Normal file
115
templates/profile.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile - DocuPulse{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">My Profile</h1>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-4 rounded-lg {% if category == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<form method="POST" action="{{ url_for('main.profile') }}" class="p-6" enctype="multipart/form-data">
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div class="relative group flex flex-col items-center">
|
||||
<label for="profile_picture" class="cursor-pointer">
|
||||
<img id="avatarPreview" src="{{ url_for('profile_pic', filename=current_user.profile_picture) if current_user.profile_picture else url_for('static', filename='default-avatar.png') }}" alt="Profile Picture" class="w-32 h-32 rounded-full object-cover border-4 border-gray-200 mb-0 transition duration-200 group-hover:opacity-80 group-hover:ring-4 group-hover:ring-primary-200 shadow-sm">
|
||||
<input id="profile_picture" type="file" name="profile_picture" accept="image/*" class="hidden" onchange="previewAvatar(event)" />
|
||||
</label>
|
||||
{% if current_user.profile_picture %}
|
||||
<button type="submit" name="remove_picture" value="1" class="mt-2 text-xs px-3 py-1 rounded bg-red-100 text-red-700 border border-red-200 hover:bg-red-200 transition">Remove Picture</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">First Name</label>
|
||||
<input type="text" name="first_name" value="{{ current_user.username }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
|
||||
<input type="text" name="last_name" value="{{ current_user.last_name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" name="email" value="{{ current_user.email }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||
<input type="tel" name="phone" value="{{ current_user.phone or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Company</label>
|
||||
<input type="text" name="company" value="{{ current_user.company or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Position</label>
|
||||
<input type="text" name="position" value="{{ current_user.position or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||
<textarea name="notes" rows="4"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">{{ current_user.notes or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Change Password</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
|
||||
<input type="password" name="new_password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Confirm New Password</label>
|
||||
<input type="password" name="confirm_password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end">
|
||||
<button type="submit"
|
||||
class="text-white px-6 py-2 rounded-lg transition duration-200"
|
||||
style="background-color: #16767b; border: 1px solid #16767b;"
|
||||
onmouseover="this.style.backgroundColor='#1a8a90'"
|
||||
onmouseout="this.style.backgroundColor='#16767b'">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function previewAvatar(event) {
|
||||
const [file] = event.target.files;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('avatarPreview').src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
102
templates/register.html
Normal file
102
templates/register.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Register - DocuPulse</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #16767b;
|
||||
--secondary-color: #741b5f;
|
||||
--primary-light: #1a8a90;
|
||||
--secondary-light: #8a2170;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
max-width: 400px;
|
||||
margin: 100px auto;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.register-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-light);
|
||||
border-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(22, 118, 123, 0.25);
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="register-container">
|
||||
<div class="card register-card">
|
||||
<div class="register-header text-center">
|
||||
<h2>Create Account</h2>
|
||||
<p class="mb-0">Join DocuPulse today</p>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Create Account</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<p class="mb-0">Already have an account? <a href="{{ url_for('auth.login') }}" class="text-decoration-none">Sign In</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1819
templates/room.html
Normal file
1819
templates/room.html
Normal file
File diff suppressed because it is too large
Load Diff
223
templates/room_members.html
Normal file
223
templates/room_members.html
Normal file
@@ -0,0 +1,223 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Room Members - {{ room.name }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||
<style>
|
||||
.badge.bg-primary.rounded-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
font-size: 1em;
|
||||
height: 32px;
|
||||
border-radius: 5px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
.btn-sm.member-action {
|
||||
min-width: 70px;
|
||||
height: 32px;
|
||||
font-size: 1em;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.form-check-inline {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.member-row {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.badge.creator-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 1em;
|
||||
height: 32px;
|
||||
border-radius: 5px;
|
||||
padding: 0 18px;
|
||||
background-color: rgba(22,118,123,0.08);
|
||||
color: #16767b;
|
||||
border: 1px solid #16767b22;
|
||||
}
|
||||
.btn-save-member {
|
||||
background-color: #16767b;
|
||||
color: #fff;
|
||||
border: 1px solid #16767b;
|
||||
min-width: 70px;
|
||||
height: 32px;
|
||||
font-size: 1em;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
transition: background 0.2s, border 0.2s;
|
||||
}
|
||||
.btn-save-member:hover {
|
||||
background-color: #1a8a90;
|
||||
border-color: #1a8a90;
|
||||
}
|
||||
.btn-remove-member {
|
||||
background-color: rgba(239,68,68,0.1);
|
||||
color: #b91c1c;
|
||||
border: 1px solid #b91c1c22;
|
||||
min-width: 70px;
|
||||
height: 32px;
|
||||
font-size: 1em;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.btn-remove-member:hover {
|
||||
background-color: #b91c1c;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Room Members - {{ room.name }}</h2>
|
||||
<p class="text-muted">Manage who has access to this room.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="card-title mb-0">Current Members</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if room.member_permissions %}
|
||||
<div class="list-group">
|
||||
{% for perm in room.member_permissions %}
|
||||
{% set member = perm.user %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center member-row">
|
||||
<div class="d-flex align-items-center">
|
||||
<img src="{{ url_for('profile_pic', filename=member.profile_picture) if member.profile_picture else url_for('static', filename='default-avatar.png') }}"
|
||||
alt="{{ member.username }}"
|
||||
class="rounded-circle me-3"
|
||||
style="width: 40px; height: 40px; object-fit: cover;">
|
||||
<div>
|
||||
<h6 class="mb-0">{{ member.username }} {{ member.last_name }}</h6>
|
||||
<small class="text-muted">{{ member.email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if member.id != room.created_by %}
|
||||
<form action="{{ url_for('rooms.update_member_permissions', room_id=room.id, user_id=member.id) }}" method="POST" class="d-flex align-items-center gap-2 auto-save-perms-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="can_view" id="can_view_{{ member.id }}" checked disabled>
|
||||
<input type="hidden" name="can_view" value="1">
|
||||
<label class="form-check-label" for="can_view_{{ member.id }}" title="View permission is always required"><i class="fas fa-eye"></i></label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="can_download" id="can_download_{{ member.id }}" {% if perm.can_download %}checked{% endif %}>
|
||||
<label class="form-check-label" for="can_download_{{ member.id }}" title="Can Download Files"><i class="fas fa-download"></i></label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="can_upload" id="can_upload_{{ member.id }}" {% if perm.can_upload %}checked{% endif %}>
|
||||
<label class="form-check-label" for="can_upload_{{ member.id }}" title="Can Upload Files"><i class="fas fa-upload"></i></label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="can_delete" id="can_delete_{{ member.id }}" {% if perm.can_delete %}checked{% endif %}>
|
||||
<label class="form-check-label" for="can_delete_{{ member.id }}" title="Can Delete Files"><i class="fas fa-trash"></i></label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="can_rename" id="can_rename_{{ member.id }}" {% if perm.can_rename %}checked{% endif %}>
|
||||
<label class="form-check-label" for="can_rename_{{ member.id }}" title="Can Rename Files"><i class="fas fa-i-cursor"></i></label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="can_move" id="can_move_{{ member.id }}" {% if perm.can_move %}checked{% endif %}>
|
||||
<label class="form-check-label" for="can_move_{{ member.id }}" title="Can Move Files"><i class="fas fa-arrows-alt"></i></label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="can_share" id="can_share_{{ member.id }}" {% if perm.can_share %}checked{% endif %}>
|
||||
<label class="form-check-label" for="can_share_{{ member.id }}" title="Can Share Files"><i class="fas fa-share-alt"></i></label>
|
||||
</div>
|
||||
</form>
|
||||
<form action="{{ url_for('rooms.remove_member', room_id=room.id, user_id=member.id) }}" method="POST" class="d-inline ms-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-remove-member px-3 d-flex align-items-center gap-1">
|
||||
<i class="fas fa-user-minus"></i> Remove
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="badge creator-badge px-3 py-2 d-flex align-items-center gap-1" style="height: 32px;">
|
||||
<i class="fas fa-user"></i> Creator
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">No members in this room yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="card-title mb-0">Add New Member</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('rooms.add_member', room_id=room.id) }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="user_id" class="form-label">Select User</label>
|
||||
<select class="form-select select2" id="user_id" name="user_id" required>
|
||||
<option value="">Search for a user...</option>
|
||||
{% for user in available_users %}
|
||||
<option value="{{ user.id }}">{{ user.username }} {{ user.last_name }} ({{ user.email }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-user-plus me-2"></i>Add Member
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('.select2').select2({
|
||||
theme: 'bootstrap-5',
|
||||
width: '100%',
|
||||
placeholder: 'Search for a user...',
|
||||
allowClear: true
|
||||
});
|
||||
// Auto-submit permission form on checkbox change
|
||||
document.querySelectorAll('.auto-save-perms-form input[type="checkbox"]').forEach(function(checkbox) {
|
||||
checkbox.addEventListener('change', function() {
|
||||
this.closest('form').submit();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
196
templates/rooms.html
Normal file
196
templates/rooms.html
Normal file
@@ -0,0 +1,196 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Rooms - DocuPulse{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Rooms</h2>
|
||||
</div>
|
||||
<div class="col text-end">
|
||||
<a href="{{ url_for('rooms.create_room') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> New Room
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Section -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<form method="GET" class="d-flex align-items-center w-100 justify-content-between" id="roomFilterForm" style="gap: 1rem;">
|
||||
<input type="text" name="search" placeholder="Search rooms..." value="{{ search }}" class="form-control flex-grow-1" id="roomSearchInput" autocomplete="off" style="min-width: 0;" />
|
||||
<button type="button" id="clearRoomsFilter" class="px-4 py-2 rounded-lg text-white font-medium transition-colors duration-200 ms-2 flex-shrink-0"
|
||||
style="background-color: #16767b; border: 1px solid #16767b;"
|
||||
onmouseover="this.style.backgroundColor='#1a8a90'"
|
||||
onmouseout="this.style.backgroundColor='#16767b'">
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||
{% for room in rooms %}
|
||||
<div class="col">
|
||||
<div class="card h-100 shadow-sm hover-shadow">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h5 class="card-title mb-0">{{ room.name }}</h5>
|
||||
<div class="text-muted small mt-1">Created on {{ room.created_at.strftime('%b %d, %Y') }}</div>
|
||||
</div>
|
||||
<span class="badge bg-light text-dark">
|
||||
<i class="fas fa-users"></i> {{ room.member_permissions|length }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="card-text text-muted">{{ room.description or 'No description' }}</p>
|
||||
<div class="d-flex align-items-center mt-3">
|
||||
<img src="{{ url_for('profile_pic', filename=room.creator.profile_picture) if room.creator.profile_picture else url_for('static', filename='default-avatar.png') }}"
|
||||
alt="Creator"
|
||||
class="rounded-circle me-2"
|
||||
style="width: 32px; height: 32px; object-fit: cover;">
|
||||
<div>
|
||||
<small class="text-muted">Created by</small>
|
||||
<div class="fw-medium">{{ room.creator.username }} {{ room.creator.last_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top-0">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('rooms.room', room_id=room.id) }}" class="btn btn-primary flex-grow-1">
|
||||
<i class="fas fa-door-open me-2"></i>Open Room
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="roomActions{{ room.id }}" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="roomActions{{ room.id }}">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('rooms.edit_room', room_id=room.id) }}">
|
||||
<i class="fas fa-edit me-2"></i>Edit Room
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('rooms.room_members', room_id=room.id) }}">
|
||||
<i class="fas fa-users me-2"></i>Manage Members
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item text-danger" data-bs-toggle="modal" data-bs-target="#deleteRoomModal{{ room.id }}">
|
||||
<i class="fas fa-trash me-2"></i>Delete Room
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<!-- Delete Room Modal -->
|
||||
<div class="modal fade" id="deleteRoomModal{{ room.id }}" tabindex="-1" aria-labelledby="deleteRoomModalLabel{{ room.id }}" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteRoomModalLabel{{ room.id }}">Move to Trash</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<i class="fas fa-trash text-danger" style="font-size: 2rem;"></i>
|
||||
<div>
|
||||
<h6 class="mb-1">Move to Trash</h6>
|
||||
<p class="text-muted mb-0" id="deleteFileName{{ room.id }}">{{ room.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
This item will be moved to trash. You can restore it from the trash page within 30 days.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form action="{{ url_for('rooms.delete_room', room_id=room.id) }}" method="POST" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash me-2"></i>Move to Trash
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hover-shadow {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-shadow:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 5px;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Debounce function
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('roomSearchInput');
|
||||
const form = document.getElementById('roomFilterForm');
|
||||
if (searchInput && form) {
|
||||
searchInput.addEventListener('input', debounce(function() {
|
||||
form.submit();
|
||||
}, 300));
|
||||
}
|
||||
// Clear button logic
|
||||
const clearBtn = document.getElementById('clearRoomsFilter');
|
||||
if (clearBtn && searchInput) {
|
||||
clearBtn.addEventListener('click', function() {
|
||||
searchInput.value = '';
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
468
templates/starred.html
Normal file
468
templates/starred.html
Normal file
@@ -0,0 +1,468 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Starred - DocuPulse{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<div class="container mt-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Starred Items</h2>
|
||||
<div class="text-muted">Your starred files and folders from all rooms</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center bg-white">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="btn-group btn-group-sm" role="group" style="margin-right: 0.5rem;">
|
||||
<button type="button" id="gridViewBtn" class="btn btn-outline-secondary active" onclick="toggleView('grid')">
|
||||
<i class="fas fa-th-large"></i>
|
||||
</button>
|
||||
<button type="button" id="listViewBtn" class="btn btn-outline-secondary" onclick="toggleView('list')">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
<h5 class="mb-0">Files</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="ms-auto" style="max-width: 300px; position: relative;">
|
||||
<input type="text" id="quickSearchInput" class="form-control form-control-sm" placeholder="Quick search files..." autocomplete="off" style="padding-right: 2rem;" />
|
||||
<button id="clearSearchBtn" type="button" style="position: absolute; right: 0.25rem; top: 50%; transform: translateY(-50%); display: none; border: none; background: transparent; font-size: 1.2rem; color: #888; cursor: pointer; z-index: 2;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fileGrid" class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-4"></div>
|
||||
<div id="fileError" class="text-danger mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Modal -->
|
||||
<div id="detailsModal" class="modal fade" tabindex="-1" aria-labelledby="detailsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="detailsModalLabel">Item Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="detailsModalBody">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentView = 'grid';
|
||||
let lastSelectedIndex = -1;
|
||||
let sortColumn = 'name'; // Set default sort column to name
|
||||
let sortDirection = 1; // 1 for ascending, -1 for descending
|
||||
let batchDeleteItems = null;
|
||||
let currentFiles = [];
|
||||
|
||||
// Initialize the view and fetch files
|
||||
async function initializeView() {
|
||||
try {
|
||||
const response = await fetch('/api/user/preferred_view');
|
||||
const data = await response.json();
|
||||
currentView = data.preferred_view || 'grid';
|
||||
} catch (error) {
|
||||
console.error('Error fetching preferred view:', error);
|
||||
currentView = 'grid';
|
||||
}
|
||||
|
||||
// First fetch files
|
||||
await fetchFiles();
|
||||
|
||||
// Then toggle view after files are loaded
|
||||
toggleView(currentView);
|
||||
|
||||
// Sort files by name by default
|
||||
sortFiles('name');
|
||||
}
|
||||
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
function toggleView(view) {
|
||||
currentView = view;
|
||||
const grid = document.getElementById('fileGrid');
|
||||
const gridBtn = document.getElementById('gridViewBtn');
|
||||
const listBtn = document.getElementById('listViewBtn');
|
||||
if (view === 'grid') {
|
||||
grid.classList.remove('table-mode');
|
||||
gridBtn.classList.add('active');
|
||||
listBtn.classList.remove('active');
|
||||
} else {
|
||||
grid.classList.add('table-mode');
|
||||
gridBtn.classList.remove('active');
|
||||
listBtn.classList.add('active');
|
||||
}
|
||||
renderFiles(currentFiles);
|
||||
// Save the new preference
|
||||
fetch('/api/user/preferred_view', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({ preferred_view: view })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Preferred view saved:', data);
|
||||
})
|
||||
.catch(error => console.error('Error saving preferred view:', error));
|
||||
}
|
||||
|
||||
function sortFiles(column) {
|
||||
if (sortColumn === column) {
|
||||
sortDirection *= -1; // Toggle direction
|
||||
} else {
|
||||
sortColumn = column;
|
||||
sortDirection = 1;
|
||||
}
|
||||
currentFiles.sort((a, b) => {
|
||||
let valA = a[column];
|
||||
let valB = b[column];
|
||||
// For size, convert to number
|
||||
if (column === 'size') {
|
||||
valA = typeof valA === 'number' ? valA : 0;
|
||||
valB = typeof valB === 'number' ? valB : 0;
|
||||
}
|
||||
// For name/type, compare as strings
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
return valA.localeCompare(valB) * sortDirection;
|
||||
}
|
||||
// For date (modified), compare as numbers
|
||||
return (valA - valB) * sortDirection;
|
||||
});
|
||||
renderFiles(currentFiles);
|
||||
}
|
||||
|
||||
function renderFiles(files) {
|
||||
if (!files) return;
|
||||
currentFiles = files;
|
||||
const grid = document.getElementById('fileGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (!files.length) {
|
||||
grid.innerHTML = '<div class="col"><div class="text-muted">No starred items yet.</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentView === 'list') {
|
||||
let table = `<table><thead><tr>
|
||||
<th></th>
|
||||
<th>Room</th>
|
||||
<th onclick="sortFiles('name')" style="cursor:pointer;">Name ${(sortColumn==='name') ? (sortDirection===1?'▲':'▼') : ''}</th>
|
||||
<th onclick="sortFiles('modified')" style="cursor:pointer;">Date Modified ${(sortColumn==='modified') ? (sortDirection===1?'▲':'▼') : ''}</th>
|
||||
<th onclick="sortFiles('type')" style="cursor:pointer;">Type ${(sortColumn==='type') ? (sortDirection===1?'▲':'▼') : ''}</th>
|
||||
<th onclick="sortFiles('size')" style="cursor:pointer;">Size ${(sortColumn==='size') ? (sortDirection===1?'▲':'▼') : ''}</th>
|
||||
<th class='file-actions'></th>
|
||||
</tr></thead><tbody>`;
|
||||
files.forEach((file, idx) => {
|
||||
let icon = file.type === 'folder'
|
||||
? `<i class='fas fa-folder' style='font-size:1.5rem;color:#16767b;'></i>`
|
||||
: `<i class='fas fa-file-alt' style='font-size:1.5rem;color:#741b5f;'></i>`;
|
||||
let size = file.size !== '-' ? (file.size > 0 ? (file.size < 1024*1024 ? (file.size/1024).toFixed(1)+' KB' : (file.size/1024/1024).toFixed(2)+' MB') : '0 KB') : '-';
|
||||
let actionsArr = [];
|
||||
let dblClickAction = '';
|
||||
if (file.type === 'folder') {
|
||||
dblClickAction = `ondblclick=\"window.location.href='/room/${file.room_id}?path=${encodeURIComponent(file.path ? file.path + '/' + file.name : file.name)}'\"`;
|
||||
} else {
|
||||
dblClickAction = `ondblclick=\"window.location.href='/api/rooms/${file.room_id}/files/${encodeURIComponent(file.name)}?path=${encodeURIComponent(file.path)}'\"`;
|
||||
}
|
||||
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='${file.starred ? 'Unstar' : 'Star'}' style='background-color:${file.starred ? 'rgba(255,215,0,0.15)' : 'rgba(22,118,123,0.08)'};color:${file.starred ? '#ffd700' : '#16767b'};' onclick='event.stopPropagation();toggleStar("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-star'></i></button>`);
|
||||
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Details' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='event.stopPropagation();showDetailsModal(${idx})'><i class='fas fa-info-circle'></i></button>`);
|
||||
const actions = actionsArr.join('');
|
||||
table += `<tr ${dblClickAction} onclick='navigateToFile(${file.room_id}, "${file.name}", "${file.path}", "${file.type}")'>
|
||||
<td class='file-icon'><span style="display:inline-flex;align-items:center;gap:1.2rem;">${icon}</span></td>
|
||||
<td class='room-name'><button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='event.stopPropagation();window.location.href="/rooms/${file.room_id}"'><i class='fas fa-door-open me-1'></i>${file.room_name || 'Room ' + file.room_id}</button></td>
|
||||
<td class='file-name' title='${file.name}'>${file.name}</td>
|
||||
<td class='file-date'>${formatDate(file.modified)}</td>
|
||||
<td class='file-type'>${file.type}</td>
|
||||
<td class='file-size'>${size}</td>
|
||||
<td class='file-actions'>${actions}</td>
|
||||
</tr>`;
|
||||
});
|
||||
table += '</tbody></table>';
|
||||
grid.innerHTML = table;
|
||||
} else {
|
||||
files.forEach((file, idx) => {
|
||||
let icon = file.type === 'folder'
|
||||
? `<i class='fas fa-folder' style='font-size:2.5rem;color:#16767b;'></i>`
|
||||
: `<i class='fas fa-file-alt' style='font-size:2.5rem;color:#741b5f;'></i>`;
|
||||
let size = file.size !== '-' ? (file.size > 0 ? (file.size < 1024*1024 ? (file.size/1024).toFixed(1)+' KB' : (file.size/1024/1024).toFixed(2)+' MB') : '0 KB') : '-';
|
||||
let actionsArr = [];
|
||||
let dblClickAction = '';
|
||||
if (file.type === 'folder') {
|
||||
dblClickAction = `ondblclick=\"window.location.href='/room/${file.room_id}?path=${encodeURIComponent(file.path ? file.path + '/' + file.name : file.name)}'\"`;
|
||||
} else {
|
||||
dblClickAction = `ondblclick=\"window.location.href='/api/rooms/${file.room_id}/files/${encodeURIComponent(file.name)}?path=${encodeURIComponent(file.path)}'\"`;
|
||||
}
|
||||
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='${file.starred ? 'Unstar' : 'Star'}' style='background-color:${file.starred ? 'rgba(255,215,0,0.15)' : 'rgba(22,118,123,0.08)'};color:${file.starred ? '#ffd700' : '#16767b'};' onclick='event.stopPropagation();toggleStar("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-star'></i></button>`);
|
||||
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Details' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='event.stopPropagation();showDetailsModal(${idx})'><i class='fas fa-info-circle'></i></button>`);
|
||||
const actions = actionsArr.join('');
|
||||
grid.innerHTML += `
|
||||
<div class='col'>
|
||||
<div class='card file-card h-100 border-0 shadow-sm position-relative' ${dblClickAction} onclick='navigateToFile(${file.room_id}, "${file.name}", "${file.path}", "${file.type}")'>
|
||||
<div class='card-body d-flex flex-column align-items-center justify-content-center text-center'>
|
||||
<div class='mb-2'>${icon}</div>
|
||||
<div class='fw-semibold file-name-ellipsis' title='${file.name}'>${file.name}</div>
|
||||
<div class='text-muted small'>${formatDate(file.modified)}</div>
|
||||
<div class='text-muted small'>${size}</div>
|
||||
<div class='mt-2'><button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='event.stopPropagation();window.location.href="/rooms/${file.room_id}"'><i class='fas fa-door-open me-1'></i>${file.room_name || 'Room ' + file.room_id}</button></div>
|
||||
</div>
|
||||
<div class='card-footer bg-white border-0 d-flex justify-content-center gap-2'>${actions}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFiles() {
|
||||
fetch('/api/rooms/starred')
|
||||
.then(r => r.json())
|
||||
.then(files => {
|
||||
if (files) {
|
||||
window.currentFiles = files;
|
||||
// Sort files by name by default
|
||||
window.currentFiles.sort((a, b) => {
|
||||
if (a.name < b.name) return -1;
|
||||
if (a.name > b.name) return 1;
|
||||
return 0;
|
||||
});
|
||||
renderFiles(files);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading files:', error);
|
||||
document.getElementById('fileGrid').innerHTML = '<div class="col"><div class="text-danger">Failed to load files. Please try refreshing the page.</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStar(filename, path = '', roomId) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
fetch(`/api/rooms/${roomId}/star`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: filename,
|
||||
path: path
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
// Remove the file from the current view since it's no longer starred
|
||||
currentFiles = currentFiles.filter(f => !(f.name === filename && f.path === path && f.room_id === roomId));
|
||||
renderFiles(currentFiles);
|
||||
} else {
|
||||
console.error('Failed to toggle star:', res.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error toggling star:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function navigateToFile(roomId, filename, path, type) {
|
||||
if (file.type === 'folder') {
|
||||
window.location.href = `/room/${roomId}?path=${encodeURIComponent(path ? path + '/' + filename : filename)}`;
|
||||
} else {
|
||||
window.location.href = `/api/rooms/${roomId}/files/${encodeURIComponent(filename)}?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function showDetailsModal(idx) {
|
||||
const item = currentFiles[idx];
|
||||
const icon = item.type === 'folder'
|
||||
? `<i class='fas fa-folder' style='font-size:2.2rem;color:#16767b;'></i>`
|
||||
: `<i class='fas fa-file-alt' style='font-size:2.2rem;color:#741b5f;'></i>`;
|
||||
const uploaderPic = item.uploader_profile_pic
|
||||
? `/uploads/profile_pics/${item.uploader_profile_pic}`
|
||||
: '/static/default-avatar.png';
|
||||
const detailsHtml = `
|
||||
<div class='d-flex align-items-center gap-3 mb-3'>
|
||||
<div>${icon}</div>
|
||||
<div style='min-width:0;'>
|
||||
<div class='fw-bold' style='font-size:1.1rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' title='${item.name}'>${item.name}</div>
|
||||
<div class='text-muted small'>${item.type === 'folder' ? 'Folder' : 'File'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='mb-2 d-flex align-items-center gap-2'>
|
||||
<img src='${uploaderPic}' alt='Profile Picture' class='rounded-circle border' style='width:28px;height:28px;object-fit:cover;'>
|
||||
<span class='fw-semibold' style='font-size:0.98rem;'>${item.uploaded_by || '-'}</span>
|
||||
</div>
|
||||
<div class='mb-2 text-muted' style='font-size:0.92rem;'><i class='far fa-clock me-1'></i>${formatDate(item.modified)}</div>
|
||||
<hr style='margin:0.7rem 0 0.5rem 0; border-color:#e6f3f4;'>
|
||||
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Room:</strong> <button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='window.location.href=\"/room/${item.room_id}\"'><i class='fas fa-door-open me-1'></i>${item.room_name || 'Room ' + item.room_id}</button></div>
|
||||
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Path:</strong> <span style='word-break:break-all;'>${(item.path ? item.path + '/' : '') + item.name}</span></div>
|
||||
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Size:</strong> ${item.size === '-' ? '-' : (item.size > 0 ? (item.size < 1024*1024 ? (item.size/1024).toFixed(1)+' KB' : (item.size/1024/1024).toFixed(2)+' MB') : '0 KB')}</div>
|
||||
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Uploaded at:</strong> ${item.uploaded_at ? new Date(item.uploaded_at).toLocaleString() : '-'}</div>
|
||||
`;
|
||||
document.getElementById('detailsModalBody').innerHTML = detailsHtml;
|
||||
var modal = new bootstrap.Modal(document.getElementById('detailsModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Live search
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeView();
|
||||
|
||||
const quickSearchInput = document.getElementById('quickSearchInput');
|
||||
const clearSearchBtn = document.getElementById('clearSearchBtn');
|
||||
let searchTimeout = null;
|
||||
|
||||
quickSearchInput.addEventListener('input', function() {
|
||||
const query = quickSearchInput.value.trim().toLowerCase();
|
||||
clearSearchBtn.style.display = query.length > 0 ? 'block' : 'none';
|
||||
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
if (query.length === 0) {
|
||||
fetchFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredFiles = currentFiles.filter(file =>
|
||||
file.name.toLowerCase().includes(query)
|
||||
);
|
||||
renderFiles(filteredFiles);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
clearSearchBtn.addEventListener('click', function() {
|
||||
quickSearchInput.value = '';
|
||||
clearSearchBtn.style.display = 'none';
|
||||
fetchFiles();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.file-name-ellipsis {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
.file-name-ellipsis { max-width: 180px; }
|
||||
}
|
||||
.file-action-btn {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.card:hover > .card-footer .file-action-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.card-footer.bg-white.border-0.d-flex.justify-content-center.gap-2 {
|
||||
min-height: 40px;
|
||||
}
|
||||
.card.file-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
.card.file-card .file-action-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
#fileGrid.table-mode {
|
||||
padding: 0;
|
||||
}
|
||||
#fileGrid.table-mode table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
}
|
||||
#fileGrid.table-mode th, #fileGrid.table-mode td {
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
#fileGrid.table-mode th {
|
||||
background: #f8f9fa;
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
}
|
||||
#fileGrid.table-mode tr:hover td {
|
||||
background-color: rgba(22, 118, 123, 0.08);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#fileGrid.table-mode .file-icon {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
#fileGrid.table-mode .file-actions {
|
||||
min-width: 90px;
|
||||
text-align: right;
|
||||
}
|
||||
#fileGrid.table-mode .file-action-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
font-size: 0.875rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
/* Disable text selection for file grid and table rows/cards */
|
||||
#fileGrid, #fileGrid * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
#fileGrid .card.file-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
#fileGrid.table-mode tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-group.btn-group-sm .btn {
|
||||
background-color: #fff;
|
||||
border-color: #e9ecef;
|
||||
color: #6c757d;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-group.btn-group-sm .btn.active, .btn-group.btn-group-sm .btn:active {
|
||||
background-color: #e6f3f4 !important;
|
||||
color: #16767b !important;
|
||||
border-color: #16767b !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-group.btn-group-sm .btn:focus {
|
||||
box-shadow: 0 0 0 0.1rem #16767b33;
|
||||
}
|
||||
.btn-group.btn-group-sm .btn:hover:not(.active) {
|
||||
background-color: #f8f9fa;
|
||||
color: #16767b;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
643
templates/trash.html
Normal file
643
templates/trash.html
Normal file
@@ -0,0 +1,643 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Trash - DocuPulse{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<div class="container mt-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Trash</h2>
|
||||
<div class="text-muted">Your deleted files and folders from all rooms</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center bg-white">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="btn-group btn-group-sm" role="group" style="margin-right: 0.5rem;">
|
||||
<button type="button" id="gridViewBtn" class="btn btn-outline-secondary active" onclick="toggleView('grid')">
|
||||
<i class="fas fa-th-large"></i>
|
||||
</button>
|
||||
<button type="button" id="listViewBtn" class="btn btn-outline-secondary" onclick="toggleView('list')">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
<h5 class="mb-0">Files</h5>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<button type="button" id="emptyTrashBtn" class="btn btn-danger btn-sm" onclick="showEmptyTrashModal()">
|
||||
<i class="fas fa-trash me-1"></i>Empty Trash
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="ms-auto" style="max-width: 300px; position: relative;">
|
||||
<input type="text" id="quickSearchInput" class="form-control form-control-sm" placeholder="Quick search files..." autocomplete="off" style="padding-right: 2rem;" />
|
||||
<button id="clearSearchBtn" type="button" style="position: absolute; right: 0.25rem; top: 50%; transform: translateY(-50%); display: none; border: none; background: transparent; font-size: 1.2rem; color: #888; cursor: pointer; z-index: 2;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fileGrid" class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-4"></div>
|
||||
<div id="fileError" class="text-danger mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Modal -->
|
||||
<div id="detailsModal" class="modal fade" tabindex="-1" aria-labelledby="detailsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="detailsModalLabel">Item Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="detailsModalBody">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permanent Delete Confirmation Modal -->
|
||||
<div id="permanentDeleteModal" class="modal fade" tabindex="-1" aria-labelledby="permanentDeleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="permanentDeleteModalLabel">Permanently Delete Item</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<i class="fas fa-exclamation-triangle text-danger" style="font-size: 2rem;"></i>
|
||||
<div>
|
||||
<h6 class="mb-1">Are you sure you want to permanently delete this item?</h6>
|
||||
<p class="text-muted mb-0" id="permanentDeleteItemName"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
This action cannot be undone. The item will be permanently removed from the system.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmPermanentDelete">
|
||||
<i class="fas fa-trash me-1"></i>Delete Permanently
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty Trash Confirmation Modal -->
|
||||
<div id="emptyTrashModal" class="modal fade" tabindex="-1" aria-labelledby="emptyTrashModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="emptyTrashModalLabel">Empty Trash</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<i class="fas fa-exclamation-triangle text-danger" style="font-size: 2rem;"></i>
|
||||
<div>
|
||||
<h6 class="mb-1">Are you sure you want to empty the trash?</h6>
|
||||
<p class="text-muted mb-0">This will permanently delete all items in the trash.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
This action cannot be undone. All items will be permanently removed from the system.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmEmptyTrash">
|
||||
<i class="fas fa-trash me-1"></i>Empty Trash
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentView = 'grid';
|
||||
let lastSelectedIndex = -1;
|
||||
let sortColumn = 'auto_delete'; // Set default sort column to auto_delete
|
||||
let sortDirection = 1; // 1 for ascending, -1 for descending
|
||||
let batchDeleteItems = null;
|
||||
let currentFiles = [];
|
||||
let fileToDelete = null;
|
||||
window.isAdmin = {{ 'true' if current_user.is_admin else 'false' }};
|
||||
|
||||
// Initialize the view and fetch files
|
||||
async function initializeView() {
|
||||
try {
|
||||
const response = await fetch('/api/user/preferred_view');
|
||||
const data = await response.json();
|
||||
currentView = data.preferred_view || 'grid';
|
||||
} catch (error) {
|
||||
console.error('Error fetching preferred view:', error);
|
||||
currentView = 'grid';
|
||||
}
|
||||
|
||||
// First fetch files
|
||||
await fetchFiles();
|
||||
|
||||
// Then toggle view after files are loaded
|
||||
toggleView(currentView);
|
||||
|
||||
// Sort files by auto_delete by default
|
||||
sortFiles('auto_delete');
|
||||
}
|
||||
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
function toggleView(view) {
|
||||
currentView = view;
|
||||
const grid = document.getElementById('fileGrid');
|
||||
const gridBtn = document.getElementById('gridViewBtn');
|
||||
const listBtn = document.getElementById('listViewBtn');
|
||||
if (view === 'grid') {
|
||||
grid.classList.remove('table-mode');
|
||||
gridBtn.classList.add('active');
|
||||
listBtn.classList.remove('active');
|
||||
} else {
|
||||
grid.classList.add('table-mode');
|
||||
gridBtn.classList.remove('active');
|
||||
listBtn.classList.add('active');
|
||||
}
|
||||
renderFiles(currentFiles);
|
||||
// Save the new preference
|
||||
fetch('/api/user/preferred_view', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({ preferred_view: view })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Preferred view saved:', data);
|
||||
})
|
||||
.catch(error => console.error('Error saving preferred view:', error));
|
||||
}
|
||||
|
||||
function sortFiles(column) {
|
||||
if (sortColumn === column) {
|
||||
sortDirection *= -1; // Toggle direction
|
||||
} else {
|
||||
sortColumn = column;
|
||||
sortDirection = 1;
|
||||
}
|
||||
currentFiles.sort((a, b) => {
|
||||
let valA = a[column];
|
||||
let valB = b[column];
|
||||
|
||||
// Special handling for auto_delete column
|
||||
if (column === 'auto_delete') {
|
||||
valA = valA ? new Date(valA).getTime() : Infinity;
|
||||
valB = valB ? new Date(valB).getTime() : Infinity;
|
||||
return (valA - valB) * sortDirection;
|
||||
}
|
||||
|
||||
// For size, convert to number
|
||||
if (column === 'size') {
|
||||
valA = typeof valA === 'number' ? valA : 0;
|
||||
valB = typeof valB === 'number' ? valB : 0;
|
||||
}
|
||||
// For name/type, compare as strings
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
return valA.localeCompare(valB) * sortDirection;
|
||||
}
|
||||
// For date (modified), compare as numbers
|
||||
return (valA - valB) * sortDirection;
|
||||
});
|
||||
renderFiles(currentFiles);
|
||||
}
|
||||
|
||||
function renderFiles(files) {
|
||||
if (!files) return;
|
||||
currentFiles = files;
|
||||
const grid = document.getElementById('fileGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (!files.length) {
|
||||
grid.innerHTML = '<div class="col"><div class="text-muted">No deleted items yet.</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentView === 'list') {
|
||||
let table = `<table><thead><tr>
|
||||
<th></th>
|
||||
<th>Room</th>
|
||||
<th onclick="sortFiles('name')" style="cursor:pointer;">Name ${(sortColumn==='name') ? (sortDirection===1?'▲':'▼') : ''}</th>
|
||||
<th onclick="sortFiles('modified')" style="cursor:pointer;">Date Modified ${(sortColumn==='modified') ? (sortDirection===1?'▲':'▼') : ''}</th>
|
||||
<th onclick="sortFiles('type')" style="cursor:pointer;">Type ${(sortColumn==='type') ? (sortDirection===1?'▲':'▼') : ''}</th>
|
||||
<th onclick="sortFiles('size')" style="cursor:pointer;">Size ${(sortColumn==='size') ? (sortDirection===1?'▲':'▼') : ''}</th>
|
||||
<th>Deleted By</th>
|
||||
<th>Deleted At</th>
|
||||
<th>Auto-Delete</th>
|
||||
<th class='file-actions'></th>
|
||||
</tr></thead><tbody>`;
|
||||
files.forEach((file, idx) => {
|
||||
let icon = file.type === 'folder'
|
||||
? `<i class='fas fa-folder' style='font-size:1.5rem;color:#16767b;'></i>`
|
||||
: `<i class='fas fa-file-alt' style='font-size:1.5rem;color:#741b5f;'></i>`;
|
||||
let size = file.size !== '-' ? (file.size > 0 ? (file.size < 1024*1024 ? (file.size/1024).toFixed(1)+' KB' : (file.size/1024/1024).toFixed(2)+' MB') : '0 KB') : '-';
|
||||
let actionsArr = [];
|
||||
if (file.can_restore) {
|
||||
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Restore' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='restoreFile("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-undo'></i></button>`);
|
||||
}
|
||||
if (window.isAdmin) {
|
||||
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Delete Permanently' style='background-color:rgba(239,68,68,0.1);color:#b91c1c;' onclick='deletePermanently("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-trash'></i></button>`);
|
||||
}
|
||||
const actions = actionsArr.join('');
|
||||
|
||||
// Calculate days until auto-deletion
|
||||
const deletedAt = new Date(file.deleted_at);
|
||||
const now = new Date();
|
||||
const daysUntilDeletion = 30 - Math.floor((now - deletedAt) / (1000 * 60 * 60 * 24));
|
||||
let autoDeleteStatus = '';
|
||||
if (daysUntilDeletion <= 0) {
|
||||
autoDeleteStatus = '<span class="badge bg-danger">Deleting soon</span>';
|
||||
} else if (daysUntilDeletion <= 7) {
|
||||
autoDeleteStatus = `<span class="badge bg-warning text-dark">${daysUntilDeletion} days left</span>`;
|
||||
} else {
|
||||
autoDeleteStatus = `<span class="badge bg-info text-dark">${daysUntilDeletion} days left</span>`;
|
||||
}
|
||||
|
||||
table += `<tr>
|
||||
<td class='file-icon'><span style="display:inline-flex;align-items:center;gap:1.2rem;">${icon}</span></td>
|
||||
<td class='room-name'><button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='window.location.href="/rooms/${file.room_id}"'><i class='fas fa-door-open me-1'></i>${file.room_name || 'Room ' + file.room_id}</button></td>
|
||||
<td class='file-name' title='${file.name}'>${file.name}</td>
|
||||
<td class='file-date'>${formatDate(file.modified)}</td>
|
||||
<td class='file-type'>${file.type}</td>
|
||||
<td class='file-size'>${size}</td>
|
||||
<td class='deleted-by'>${file.deleted_by || '-'}</td>
|
||||
<td class='deleted-at'>${file.deleted_at ? new Date(file.deleted_at).toLocaleString() : '-'}</td>
|
||||
<td class='auto-delete'>${autoDeleteStatus}</td>
|
||||
<td class='file-actions'>${actions}</td>
|
||||
</tr>`;
|
||||
});
|
||||
table += '</tbody></table>';
|
||||
grid.innerHTML = table;
|
||||
} else {
|
||||
files.forEach((file, idx) => {
|
||||
let icon = file.type === 'folder'
|
||||
? `<i class='fas fa-folder' style='font-size:2.5rem;color:#16767b;'></i>`
|
||||
: `<i class='fas fa-file-alt' style='font-size:2.5rem;color:#741b5f;'></i>`;
|
||||
let size = file.size !== '-' ? (file.size > 0 ? (file.size < 1024*1024 ? (file.size/1024).toFixed(1)+' KB' : (file.size/1024/1024).toFixed(2)+' MB') : '0 KB') : '-';
|
||||
let actionsArr = [];
|
||||
if (file.can_restore) {
|
||||
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Restore' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='restoreFile("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-undo'></i></button>`);
|
||||
}
|
||||
if (window.isAdmin) {
|
||||
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Delete Permanently' style='background-color:rgba(239,68,68,0.1);color:#b91c1c;' onclick='deletePermanently("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-trash'></i></button>`);
|
||||
}
|
||||
const actions = actionsArr.join('');
|
||||
|
||||
// Calculate days until auto-deletion
|
||||
const deletedAt = new Date(file.deleted_at);
|
||||
const now = new Date();
|
||||
const daysUntilDeletion = 30 - Math.floor((now - deletedAt) / (1000 * 60 * 60 * 24));
|
||||
let autoDeleteStatus = '';
|
||||
if (daysUntilDeletion <= 0) {
|
||||
autoDeleteStatus = '<span class="badge bg-danger">Deleting soon</span>';
|
||||
} else if (daysUntilDeletion <= 7) {
|
||||
autoDeleteStatus = `<span class="badge bg-warning text-dark">${daysUntilDeletion} days left</span>`;
|
||||
} else {
|
||||
autoDeleteStatus = `<span class="badge bg-info text-dark">${daysUntilDeletion} days left</span>`;
|
||||
}
|
||||
|
||||
grid.innerHTML += `
|
||||
<div class='col'>
|
||||
<div class='card file-card h-100 border-0 shadow-sm position-relative'>
|
||||
<div class='card-body d-flex flex-column align-items-center justify-content-center text-center'>
|
||||
<div class='mb-2'>${icon}</div>
|
||||
<div class='fw-semibold file-name-ellipsis' title='${file.name}'>${file.name}</div>
|
||||
<div class='text-muted small'>${formatDate(file.modified)}</div>
|
||||
<div class='text-muted small'>${size}</div>
|
||||
<div class='mt-2'><button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='window.location.href="/rooms/${file.room_id}"'><i class='fas fa-door-open me-1'></i>${file.room_name || 'Room ' + file.room_id}</button></div>
|
||||
<div class='text-muted small mt-1'>Deleted by: ${file.deleted_by || '-'}</div>
|
||||
<div class='text-muted small'>${file.deleted_at ? new Date(file.deleted_at).toLocaleString() : '-'}</div>
|
||||
<div class='mt-2'>${autoDeleteStatus}</div>
|
||||
</div>
|
||||
<div class='card-footer bg-white border-0 d-flex justify-content-center gap-2'>${actions}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFiles() {
|
||||
fetch('/api/rooms/trash')
|
||||
.then(r => r.json())
|
||||
.then(files => {
|
||||
if (files) {
|
||||
window.currentFiles = files;
|
||||
// Sort files by auto_delete by default
|
||||
window.currentFiles.sort((a, b) => {
|
||||
const timeA = a.auto_delete ? new Date(a.auto_delete).getTime() : Infinity;
|
||||
const timeB = b.auto_delete ? new Date(b.auto_delete).getTime() : Infinity;
|
||||
return timeA - timeB;
|
||||
});
|
||||
renderFiles(files);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading files:', error);
|
||||
document.getElementById('fileGrid').innerHTML = '<div class="col"><div class="text-danger">Failed to load files. Please try refreshing the page.</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
function restoreFile(filename, path, roomId) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
fetch(`/api/rooms/${roomId}/restore`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: filename,
|
||||
path: path
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
// Remove the file from the current view since it's been restored
|
||||
currentFiles = currentFiles.filter(f => !(f.name === filename && f.path === path && f.room_id === roomId));
|
||||
renderFiles(currentFiles);
|
||||
} else {
|
||||
console.error('Failed to restore file:', res.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error restoring file:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePermanently(filename, path, roomId) {
|
||||
fileToDelete = { filename, path, roomId };
|
||||
const modal = new bootstrap.Modal(document.getElementById('permanentDeleteModal'));
|
||||
document.getElementById('permanentDeleteItemName').textContent = filename;
|
||||
modal.show();
|
||||
}
|
||||
|
||||
document.getElementById('confirmPermanentDelete').addEventListener('click', function() {
|
||||
if (!fileToDelete) return;
|
||||
|
||||
const { filename, path, roomId } = fileToDelete;
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
|
||||
fetch(`/api/rooms/${roomId}/delete-permanent`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: filename,
|
||||
path: path
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
// Remove the file from the current view since it's been permanently deleted
|
||||
currentFiles = currentFiles.filter(f => !(f.name === filename && f.path === path && f.room_id === roomId));
|
||||
renderFiles(currentFiles);
|
||||
// Close the modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('permanentDeleteModal')).hide();
|
||||
} else {
|
||||
console.error('Failed to delete file permanently:', res.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting file permanently:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
fileToDelete = null;
|
||||
});
|
||||
});
|
||||
|
||||
function showDetailsModal(idx) {
|
||||
const item = currentFiles[idx];
|
||||
const icon = item.type === 'folder'
|
||||
? `<i class='fas fa-folder' style='font-size:2.2rem;color:#16767b;'></i>`
|
||||
: `<i class='fas fa-file-alt' style='font-size:2.2rem;color:#741b5f;'></i>`;
|
||||
const uploaderPic = item.uploader_profile_pic
|
||||
? `/uploads/profile_pics/${item.uploader_profile_pic}`
|
||||
: '/static/default-avatar.png';
|
||||
const detailsHtml = `
|
||||
<div class='d-flex align-items-center gap-3 mb-3'>
|
||||
<div>${icon}</div>
|
||||
<div style='min-width:0;'>
|
||||
<div class='fw-bold' style='font-size:1.1rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' title='${item.name}'>${item.name}</div>
|
||||
<div class='text-muted small'>${item.type === 'folder' ? 'Folder' : 'File'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='mb-2 d-flex align-items-center gap-2'>
|
||||
<img src='${uploaderPic}' alt='Profile Picture' class='rounded-circle border' style='width:28px;height:28px;object-fit:cover;'>
|
||||
<span class='fw-semibold' style='font-size:0.98rem;'>${item.uploaded_by || '-'}</span>
|
||||
</div>
|
||||
<div class='mb-2 text-muted' style='font-size:0.92rem;'><i class='far fa-clock me-1'></i>${formatDate(item.modified)}</div>
|
||||
<hr style='margin:0.7rem 0 0.5rem 0; border-color:#e6f3f4;'>
|
||||
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Room:</strong> <button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='window.location.href=\"/room/${item.room_id}\"'><i class='fas fa-door-open me-1'></i>${item.room_name || 'Room ' + item.room_id}</button></div>
|
||||
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Path:</strong> <span style='word-break:break-all;'>${(item.path ? item.path + '/' : '') + item.name}</span></div>
|
||||
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Size:</strong> ${item.size === '-' ? '-' : (item.size > 0 ? (item.size < 1024*1024 ? (item.size/1024).toFixed(1)+' KB' : (item.size/1024/1024).toFixed(2)+' MB') : '0 KB')}</div>
|
||||
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Uploaded at:</strong> ${item.uploaded_at ? new Date(item.uploaded_at).toLocaleString() : '-'}</div>
|
||||
`;
|
||||
document.getElementById('detailsModalBody').innerHTML = detailsHtml;
|
||||
var modal = new bootstrap.Modal(document.getElementById('detailsModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Live search
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeView();
|
||||
|
||||
const quickSearchInput = document.getElementById('quickSearchInput');
|
||||
const clearSearchBtn = document.getElementById('clearSearchBtn');
|
||||
let searchTimeout = null;
|
||||
|
||||
quickSearchInput.addEventListener('input', function() {
|
||||
const query = quickSearchInput.value.trim().toLowerCase();
|
||||
clearSearchBtn.style.display = query.length > 0 ? 'block' : 'none';
|
||||
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
if (query.length === 0) {
|
||||
fetchFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredFiles = currentFiles.filter(file =>
|
||||
file.name.toLowerCase().includes(query)
|
||||
);
|
||||
renderFiles(filteredFiles);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
clearSearchBtn.addEventListener('click', function() {
|
||||
quickSearchInput.value = '';
|
||||
clearSearchBtn.style.display = 'none';
|
||||
fetchFiles();
|
||||
});
|
||||
});
|
||||
|
||||
function showEmptyTrashModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('emptyTrashModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
document.getElementById('confirmEmptyTrash').addEventListener('click', function() {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
|
||||
// Get all unique room IDs from current files
|
||||
const roomIds = [...new Set(currentFiles.map(file => file.room_id))];
|
||||
|
||||
// Create an array of promises for emptying trash in each room
|
||||
const emptyPromises = roomIds.map(roomId =>
|
||||
fetch(`/api/rooms/${roomId}/delete-permanent`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: '*', // Special value to indicate all files
|
||||
path: '' // Empty path to indicate root
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Execute all promises
|
||||
Promise.all(emptyPromises)
|
||||
.then(responses => {
|
||||
// Check if all responses were successful
|
||||
const allSuccessful = responses.every(response => response.ok);
|
||||
if (allSuccessful) {
|
||||
// Clear the current files array and re-render
|
||||
currentFiles = [];
|
||||
renderFiles(currentFiles);
|
||||
// Close the modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('emptyTrashModal')).hide();
|
||||
// Refresh the files list to ensure we have the latest state
|
||||
fetchFiles();
|
||||
} else {
|
||||
console.error('Failed to empty trash in some rooms');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error emptying trash:', error);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.file-name-ellipsis {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
.file-name-ellipsis { max-width: 180px; }
|
||||
}
|
||||
.file-action-btn {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.card:hover > .card-footer .file-action-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.card-footer.bg-white.border-0.d-flex.justify-content-center.gap-2 {
|
||||
min-height: 40px;
|
||||
}
|
||||
.card.file-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
.card.file-card .file-action-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
#fileGrid.table-mode {
|
||||
padding: 0;
|
||||
}
|
||||
#fileGrid.table-mode table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
}
|
||||
#fileGrid.table-mode th, #fileGrid.table-mode td {
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
#fileGrid.table-mode th {
|
||||
background: #f8f9fa;
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
}
|
||||
#fileGrid.table-mode tr:hover td {
|
||||
background-color: rgba(22, 118, 123, 0.08);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#fileGrid.table-mode .file-icon {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
#fileGrid.table-mode .file-actions {
|
||||
min-width: 90px;
|
||||
text-align: right;
|
||||
}
|
||||
#fileGrid.table-mode .file-action-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
font-size: 0.875rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
/* Disable text selection for file grid and table rows/cards */
|
||||
#fileGrid, #fileGrid * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
#fileGrid .card.file-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
#fileGrid.table-mode tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user