diff --git a/app.py b/app.py index 6cafd4e..8d1e146 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file, abort -from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, CompanyInvitation +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, CompanyInvitation, Note, NoteFolder, NoteShare from data_formatting import ( format_duration, prepare_export_data, prepare_team_hours_export_data, format_table_data, format_graph_data, format_team_data, format_burndown_data @@ -20,9 +20,10 @@ from werkzeug.security import check_password_hash # Import blueprints -# from routes.notes import notes_bp -# from routes.notes_download import notes_download_bp -# from routes.notes_api import notes_api_bp +from routes.notes import notes_bp +from routes.notes_download import notes_download_bp +from routes.notes_api import notes_api_bp +from routes.notes_public import notes_public_bp from routes.tasks import tasks_bp, get_filtered_tasks_for_burndown from routes.tasks_api import tasks_api_bp from routes.sprints import sprints_bp @@ -39,6 +40,7 @@ from routes.announcements import announcements_bp from routes.export import export_bp from routes.export_api import export_api_bp +from routes.organization import organization_bp # Import auth decorators from routes.auth from routes.auth import login_required, admin_required, system_admin_required, role_required, company_required @@ -84,9 +86,10 @@ db.init_app(app) # Register blueprints -# app.register_blueprint(notes_bp) -# app.register_blueprint(notes_download_bp) -# app.register_blueprint(notes_api_bp) +app.register_blueprint(notes_bp) +app.register_blueprint(notes_download_bp) +app.register_blueprint(notes_api_bp) +app.register_blueprint(notes_public_bp) app.register_blueprint(tasks_bp) app.register_blueprint(tasks_api_bp) app.register_blueprint(sprints_bp) @@ -103,6 +106,7 @@ app.register_blueprint(announcements_bp) app.register_blueprint(export_bp) app.register_blueprint(export_api_bp) +app.register_blueprint(organization_bp) # Import and register invitations blueprint from routes.invitations import invitations_bp @@ -829,8 +833,10 @@ def verify_email(token): @role_required(Role.TEAM_MEMBER) @company_required def dashboard(): - """User dashboard with configurable widgets.""" - return render_template('dashboard.html', title='Dashboard') + """User dashboard with configurable widgets - DISABLED due to widget issues.""" + # Redirect to home page instead of dashboard + flash('Dashboard is temporarily disabled. Redirecting to home page.', 'info') + return redirect(url_for('index')) @app.route('/profile', methods=['GET', 'POST']) @@ -2666,6 +2672,36 @@ def search_sprints(): logger.error(f"Error in search_sprints: {str(e)}") return jsonify({'success': False, 'message': str(e)}) +@app.route('/api/render-markdown', methods=['POST']) +@login_required +def render_markdown(): + """Render markdown content to HTML for preview""" + try: + data = request.get_json() + content = data.get('content', '') + + if not content: + return jsonify({'html': '
Start typing to see the preview...
'}) + + # Parse frontmatter and extract body + from frontmatter_utils import parse_frontmatter + metadata, body = parse_frontmatter(content) + + # Render markdown to HTML + try: + import markdown + # Use extensions for better markdown support + html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc', 'tables', 'fenced_code']) + except ImportError: + # Fallback if markdown not installed + html = f'{body}'
+
+ return jsonify({'html': html})
+
+ except Exception as e:
+ logger.error(f"Error rendering markdown: {str(e)}")
+ return jsonify({'html': 'Error rendering markdown
'}) + if __name__ == '__main__': port = int(os.environ.get('PORT', 5000)) app.run(debug=True, host='0.0.0.0', port=port) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1d8e496..259bc97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,18 +29,20 @@ services: timetrack: build: . - environment: - FLASK_ENV: ${FLASK_ENV:-production} - SECRET_KEY: ${SECRET_KEY} - DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} - MAIL_SERVER: ${MAIL_SERVER} - MAIL_PORT: ${MAIL_PORT} - MAIL_USE_TLS: ${MAIL_USE_TLS} - MAIL_USERNAME: ${MAIL_USERNAME} - MAIL_PASSWORD: ${MAIL_PASSWORD} - MAIL_DEFAULT_SENDER: ${MAIL_DEFAULT_SENDER} ports: - "${TIMETRACK_PORT:-5000}:5000" + environment: + - DATABASE_URL=${DATABASE_URL} + - POSTGRES_HOST=db + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - FLASK_ENV=${FLASK_ENV} + - SECRET_KEY=${SECRET_KEY} + - MAIL_SERVER=${MAIL_SERVER} + - MAIL_USERNAME=${MAIL_USERNAME} + - MAIL_PASSWORD=${MAIL_PASSWORD} + - MAIL_DEFAULT_SENDER=${MAIL_DEFAULT_SENDER} depends_on: db: condition: service_healthy diff --git a/frontmatter_utils.py b/frontmatter_utils.py new file mode 100644 index 0000000..b8aa906 --- /dev/null +++ b/frontmatter_utils.py @@ -0,0 +1,70 @@ +import yaml +import re +from datetime import datetime + +def parse_frontmatter(content): + """ + Parse YAML frontmatter from markdown content. + Returns a tuple of (metadata dict, content without frontmatter) + """ + if not content or not content.strip().startswith('---'): + return {}, content + + # Match frontmatter pattern + pattern = r'^---\s*\n(.*?)\n---\s*\n(.*)$' + match = re.match(pattern, content, re.DOTALL) + + if not match: + return {}, content + + try: + # Parse YAML frontmatter + metadata = yaml.safe_load(match.group(1)) or {} + content_body = match.group(2) + return metadata, content_body + except yaml.YAMLError: + # If YAML parsing fails, return original content + return {}, content + +def create_frontmatter(metadata): + """ + Create YAML frontmatter from metadata dict. + """ + if not metadata: + return "" + + # Filter out None values and empty strings + filtered_metadata = {k: v for k, v in metadata.items() if v is not None and v != ''} + + if not filtered_metadata: + return "" + + return f"---\n{yaml.dump(filtered_metadata, default_flow_style=False, sort_keys=False)}---\n\n" + +def update_frontmatter(content, metadata): + """ + Update or add frontmatter to content. + """ + _, body = parse_frontmatter(content) + frontmatter = create_frontmatter(metadata) + return frontmatter + body + +def extract_title_from_content(content): + """ + Extract title from content, checking frontmatter first, then first line. + """ + metadata, body = parse_frontmatter(content) + + # Check if title is in frontmatter + if metadata.get('title'): + return metadata['title'] + + # Otherwise extract from first line of body + lines = body.strip().split('\n') + for line in lines: + line = line.strip() + if line: + # Remove markdown headers if present + return re.sub(r'^#+\s*', '', line) + + return 'Untitled Note' \ No newline at end of file diff --git a/migrations/add_cascade_delete_note_links.sql b/migrations/add_cascade_delete_note_links.sql new file mode 100644 index 0000000..697aa16 --- /dev/null +++ b/migrations/add_cascade_delete_note_links.sql @@ -0,0 +1,20 @@ +-- Migration to add CASCADE delete to note_link foreign keys +-- This ensures that when a note is deleted, all links to/from it are also deleted + +-- For PostgreSQL +-- Drop existing foreign key constraints +ALTER TABLE note_link DROP CONSTRAINT IF EXISTS note_link_source_note_id_fkey; +ALTER TABLE note_link DROP CONSTRAINT IF EXISTS note_link_target_note_id_fkey; + +-- Add new foreign key constraints with CASCADE +ALTER TABLE note_link + ADD CONSTRAINT note_link_source_note_id_fkey + FOREIGN KEY (source_note_id) + REFERENCES note(id) + ON DELETE CASCADE; + +ALTER TABLE note_link + ADD CONSTRAINT note_link_target_note_id_fkey + FOREIGN KEY (target_note_id) + REFERENCES note(id) + ON DELETE CASCADE; \ No newline at end of file diff --git a/migrations/add_cascade_delete_note_links_sqlite.sql b/migrations/add_cascade_delete_note_links_sqlite.sql new file mode 100644 index 0000000..3816bfe --- /dev/null +++ b/migrations/add_cascade_delete_note_links_sqlite.sql @@ -0,0 +1,25 @@ +-- SQLite migration for cascade delete on note_link +-- SQLite doesn't support ALTER TABLE for foreign keys, so we need to recreate the table + +-- Create new table with CASCADE delete +CREATE TABLE note_link_new ( + id INTEGER PRIMARY KEY, + source_note_id INTEGER NOT NULL, + target_note_id INTEGER NOT NULL, + link_type VARCHAR(50) DEFAULT 'related', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL, + FOREIGN KEY (source_note_id) REFERENCES note(id) ON DELETE CASCADE, + FOREIGN KEY (target_note_id) REFERENCES note(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_id) REFERENCES user(id), + UNIQUE(source_note_id, target_note_id) +); + +-- Copy data from old table +INSERT INTO note_link_new SELECT * FROM note_link; + +-- Drop old table +DROP TABLE note_link; + +-- Rename new table +ALTER TABLE note_link_new RENAME TO note_link; \ No newline at end of file diff --git a/migrations/add_folder_to_notes.sql b/migrations/add_folder_to_notes.sql new file mode 100644 index 0000000..e7bf3fb --- /dev/null +++ b/migrations/add_folder_to_notes.sql @@ -0,0 +1,5 @@ +-- Add folder column to notes table +ALTER TABLE note ADD COLUMN IF NOT EXISTS folder VARCHAR(100); + +-- Create an index on folder for faster filtering +CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder) WHERE folder IS NOT NULL; \ No newline at end of file diff --git a/migrations/add_note_folder_table.sql b/migrations/add_note_folder_table.sql new file mode 100644 index 0000000..4b1687a --- /dev/null +++ b/migrations/add_note_folder_table.sql @@ -0,0 +1,17 @@ +-- Create note_folder table for tracking folders independently of notes +CREATE TABLE IF NOT EXISTS note_folder ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + path VARCHAR(500) NOT NULL, + parent_path VARCHAR(500), + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL REFERENCES "user"(id), + company_id INTEGER NOT NULL REFERENCES company(id), + CONSTRAINT uq_folder_path_company UNIQUE (path, company_id) +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_note_folder_company ON note_folder(company_id); +CREATE INDEX IF NOT EXISTS idx_note_folder_parent_path ON note_folder(parent_path); +CREATE INDEX IF NOT EXISTS idx_note_folder_created_by ON note_folder(created_by_id); \ No newline at end of file diff --git a/migrations/add_note_sharing.sql b/migrations/add_note_sharing.sql new file mode 100644 index 0000000..5cae4cc --- /dev/null +++ b/migrations/add_note_sharing.sql @@ -0,0 +1,21 @@ +-- Add note_share table for public note sharing functionality +CREATE TABLE IF NOT EXISTS note_share ( + id SERIAL PRIMARY KEY, + note_id INTEGER NOT NULL REFERENCES note(id) ON DELETE CASCADE, + token VARCHAR(64) UNIQUE NOT NULL, + expires_at TIMESTAMP, + password_hash VARCHAR(255), + view_count INTEGER DEFAULT 0, + max_views INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL REFERENCES "user"(id), + last_accessed_at TIMESTAMP +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_note_share_token ON note_share(token); +CREATE INDEX IF NOT EXISTS idx_note_share_note_id ON note_share(note_id); +CREATE INDEX IF NOT EXISTS idx_note_share_created_by ON note_share(created_by_id); + +-- Add comment +COMMENT ON TABLE note_share IS 'Public sharing links for notes with optional password protection and view limits'; \ No newline at end of file diff --git a/migrations/old_migrations/01_migrate_db.py b/migrations/old_migrations/01_migrate_db.py index 5bb67aa..4ef819a 100644 --- a/migrations/old_migrations/01_migrate_db.py +++ b/migrations/old_migrations/01_migrate_db.py @@ -79,6 +79,8 @@ def run_all_migrations(db_path=None): migrate_system_events(db_path) migrate_dashboard_system(db_path) migrate_comment_system(db_path) + migrate_notes_system(db_path) + update_note_link_cascade(db_path) # Run PostgreSQL-specific migrations if applicable if FLASK_AVAILABLE: @@ -1275,6 +1277,126 @@ def migrate_postgresql_schema(): """)) db.session.commit() + # Check if note table exists + result = db.session.execute(text(""" + SELECT table_name + FROM information_schema.tables + WHERE table_name = 'note' + """)) + + if result.fetchone(): + # Table exists, check for folder column + result = db.session.execute(text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'note' AND column_name = 'folder' + """)) + + if not result.fetchone(): + print("Adding folder column to note table...") + db.session.execute(text("ALTER TABLE note ADD COLUMN folder VARCHAR(100)")) + db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder)")) + db.session.commit() + print("Folder column added successfully!") + else: + print("Creating note and note_link tables...") + + # Create NoteVisibility enum type + db.session.execute(text(""" + DO $$ BEGIN + CREATE TYPE notevisibility AS ENUM ('Private', 'Team', 'Company'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """)) + + db.session.execute(text(""" + CREATE TABLE note ( + id SERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + slug VARCHAR(100) NOT NULL, + visibility notevisibility NOT NULL DEFAULT 'Private', + folder VARCHAR(100), + company_id INTEGER NOT NULL, + created_by_id INTEGER NOT NULL, + project_id INTEGER, + task_id INTEGER, + tags TEXT[], + is_archived BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (company_id) REFERENCES company (id), + FOREIGN KEY (created_by_id) REFERENCES "user" (id), + FOREIGN KEY (project_id) REFERENCES project (id), + FOREIGN KEY (task_id) REFERENCES task (id) + ) + """)) + + # Create note_link table + db.session.execute(text(""" + CREATE TABLE note_link ( + source_note_id INTEGER NOT NULL, + target_note_id INTEGER NOT NULL, + link_type VARCHAR(50) DEFAULT 'related', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (source_note_id, target_note_id), + FOREIGN KEY (source_note_id) REFERENCES note (id) ON DELETE CASCADE, + FOREIGN KEY (target_note_id) REFERENCES note (id) ON DELETE CASCADE + ) + """)) + + # Check if note_folder table exists + result = db.session.execute(text(""" + SELECT table_name + FROM information_schema.tables + WHERE table_name = 'note_folder' + """)) + + if not result.fetchone(): + print("Creating note_folder table...") + db.session.execute(text(""" + CREATE TABLE note_folder ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + path VARCHAR(500) NOT NULL, + parent_path VARCHAR(500), + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL, + company_id INTEGER NOT NULL, + FOREIGN KEY (created_by_id) REFERENCES "user" (id), + FOREIGN KEY (company_id) REFERENCES company (id), + CONSTRAINT uq_folder_path_company UNIQUE (path, company_id) + ) + """)) + + # Create indexes + db.session.execute(text("CREATE INDEX idx_note_company ON note(company_id)")) + db.session.execute(text("CREATE INDEX idx_note_created_by ON note(created_by_id)")) + db.session.execute(text("CREATE INDEX idx_note_project ON note(project_id)")) + db.session.execute(text("CREATE INDEX idx_note_task ON note(task_id)")) + db.session.execute(text("CREATE INDEX idx_note_slug ON note(company_id, slug)")) + db.session.execute(text("CREATE INDEX idx_note_visibility ON note(visibility)")) + db.session.execute(text("CREATE INDEX idx_note_archived ON note(is_archived)")) + db.session.execute(text("CREATE INDEX idx_note_created_at ON note(created_at DESC)")) + db.session.execute(text("CREATE INDEX idx_note_folder ON note(folder)")) + db.session.execute(text("CREATE INDEX idx_note_link_source ON note_link(source_note_id)")) + db.session.execute(text("CREATE INDEX idx_note_link_target ON note_link(target_note_id)")) + + # Create indexes for note_folder if table was created + result = db.session.execute(text(""" + SELECT table_name + FROM information_schema.tables + WHERE table_name = 'note_folder' + """)) + if result.fetchone(): + db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_company ON note_folder(company_id)")) + db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_parent_path ON note_folder(parent_path)")) + db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_created_by ON note_folder(created_by_id)")) + + db.session.commit() + print("PostgreSQL schema migration completed successfully!") except Exception as e: @@ -1485,6 +1607,222 @@ def migrate_comment_system(db_file=None): conn.close() +def migrate_notes_system(db_file=None): + """Migrate to add Notes system with markdown support.""" + db_path = get_db_path(db_file) + + print(f"Migrating Notes system in {db_path}...") + + if not os.path.exists(db_path): + print(f"Database file {db_path} does not exist. Run basic migration first.") + return False + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Check if note table already exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note'") + if cursor.fetchone(): + print("Note table already exists. Checking for updates...") + + # Check if folder column exists + cursor.execute("PRAGMA table_info(note)") + columns = [column[1] for column in cursor.fetchall()] + + if 'folder' not in columns: + print("Adding folder column to note table...") + cursor.execute("ALTER TABLE note ADD COLUMN folder VARCHAR(100)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder)") + conn.commit() + print("Folder column added successfully!") + + # Check if note_folder table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note_folder'") + if not cursor.fetchone(): + print("Creating note_folder table...") + cursor.execute(""" + CREATE TABLE note_folder ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + path VARCHAR(500) NOT NULL, + parent_path VARCHAR(500), + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL, + company_id INTEGER NOT NULL, + FOREIGN KEY (created_by_id) REFERENCES user(id), + FOREIGN KEY (company_id) REFERENCES company(id), + UNIQUE(path, company_id) + ) + """) + + # Create indexes for note_folder + cursor.execute("CREATE INDEX idx_note_folder_company ON note_folder(company_id)") + cursor.execute("CREATE INDEX idx_note_folder_parent_path ON note_folder(parent_path)") + cursor.execute("CREATE INDEX idx_note_folder_created_by ON note_folder(created_by_id)") + conn.commit() + print("Note folder table created successfully!") + + return True + + print("Creating Notes system tables...") + + # Create note table + cursor.execute(""" + CREATE TABLE note ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + slug VARCHAR(100) NOT NULL, + visibility VARCHAR(20) NOT NULL DEFAULT 'Private', + folder VARCHAR(100), + company_id INTEGER NOT NULL, + created_by_id INTEGER NOT NULL, + project_id INTEGER, + task_id INTEGER, + tags TEXT, + archived BOOLEAN DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (company_id) REFERENCES company (id), + FOREIGN KEY (created_by_id) REFERENCES user (id), + FOREIGN KEY (project_id) REFERENCES project (id), + FOREIGN KEY (task_id) REFERENCES task (id) + ) + """) + + # Create note_link table for linking notes + cursor.execute(""" + CREATE TABLE note_link ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_note_id INTEGER NOT NULL, + target_note_id INTEGER NOT NULL, + link_type VARCHAR(50) DEFAULT 'related', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL, + FOREIGN KEY (source_note_id) REFERENCES note (id) ON DELETE CASCADE, + FOREIGN KEY (target_note_id) REFERENCES note (id) ON DELETE CASCADE, + FOREIGN KEY (created_by_id) REFERENCES user (id), + UNIQUE(source_note_id, target_note_id) + ) + """) + + # Create indexes for better performance + cursor.execute("CREATE INDEX idx_note_company ON note(company_id)") + cursor.execute("CREATE INDEX idx_note_created_by ON note(created_by_id)") + cursor.execute("CREATE INDEX idx_note_project ON note(project_id)") + cursor.execute("CREATE INDEX idx_note_task ON note(task_id)") + cursor.execute("CREATE INDEX idx_note_slug ON note(company_id, slug)") + cursor.execute("CREATE INDEX idx_note_visibility ON note(visibility)") + cursor.execute("CREATE INDEX idx_note_archived ON note(archived)") + cursor.execute("CREATE INDEX idx_note_created_at ON note(created_at DESC)") + + # Create indexes for note links + cursor.execute("CREATE INDEX idx_note_link_source ON note_link(source_note_id)") + cursor.execute("CREATE INDEX idx_note_link_target ON note_link(target_note_id)") + + # Create note_folder table + cursor.execute(""" + CREATE TABLE note_folder ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + path VARCHAR(500) NOT NULL, + parent_path VARCHAR(500), + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL, + company_id INTEGER NOT NULL, + FOREIGN KEY (created_by_id) REFERENCES user(id), + FOREIGN KEY (company_id) REFERENCES company(id), + UNIQUE(path, company_id) + ) + """) + + # Create indexes for note_folder + cursor.execute("CREATE INDEX idx_note_folder_company ON note_folder(company_id)") + cursor.execute("CREATE INDEX idx_note_folder_parent_path ON note_folder(parent_path)") + cursor.execute("CREATE INDEX idx_note_folder_created_by ON note_folder(created_by_id)") + + conn.commit() + print("Notes system migration completed successfully!") + return True + + except Exception as e: + print(f"Error during Notes system migration: {e}") + conn.rollback() + return False + + finally: + conn.close() + + +def update_note_link_cascade(db_path): + """Update note_link table to ensure CASCADE delete is enabled.""" + print("Checking note_link cascade delete constraints...") + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check if note_link table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note_link'") + if not cursor.fetchone(): + print("note_link table does not exist, skipping cascade update") + return + + # Check current foreign key constraints + cursor.execute("PRAGMA foreign_key_list(note_link)") + fk_info = cursor.fetchall() + + # Check if CASCADE is already set + has_cascade = any('CASCADE' in str(fk) for fk in fk_info) + + if not has_cascade: + print("Updating note_link table with CASCADE delete...") + + # SQLite doesn't support ALTER TABLE for foreign keys, so recreate the table + cursor.execute(""" + CREATE TABLE note_link_temp ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_note_id INTEGER NOT NULL, + target_note_id INTEGER NOT NULL, + link_type VARCHAR(50) DEFAULT 'related', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL, + FOREIGN KEY (source_note_id) REFERENCES note(id) ON DELETE CASCADE, + FOREIGN KEY (target_note_id) REFERENCES note(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_id) REFERENCES user(id), + UNIQUE(source_note_id, target_note_id) + ) + """) + + # Copy data + cursor.execute("INSERT INTO note_link_temp SELECT * FROM note_link") + + # Drop old table and rename new one + cursor.execute("DROP TABLE note_link") + cursor.execute("ALTER TABLE note_link_temp RENAME TO note_link") + + # Recreate indexes + cursor.execute("CREATE INDEX idx_note_link_source ON note_link(source_note_id)") + cursor.execute("CREATE INDEX idx_note_link_target ON note_link(target_note_id)") + + print("note_link table updated with CASCADE delete") + else: + print("note_link table already has CASCADE delete") + + conn.commit() + + except Exception as e: + print(f"Error updating note_link cascade: {e}") + if conn: + conn.rollback() + finally: + if conn: + conn.close() + + def main(): """Main function with command line interface.""" parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool') diff --git a/migrations/run_postgres_migrations.py b/migrations/run_postgres_migrations.py index 235ee90..681b323 100755 --- a/migrations/run_postgres_migrations.py +++ b/migrations/run_postgres_migrations.py @@ -17,6 +17,7 @@ # List of PostgreSQL migrations in order POSTGRES_MIGRATIONS = [ 'postgres_only_migration.py', # Main migration from commit 4214e88 onward + 'add_note_sharing.sql', # Add note sharing functionality ] @@ -49,12 +50,39 @@ def run_migration(migration_file): print(f"\nπ Running migration: {migration_file}") try: - # Run the migration script - result = subprocess.run( - [sys.executable, script_path], - capture_output=True, - text=True - ) + # Check if it's a SQL file + if migration_file.endswith('.sql'): + # Run SQL file using psql + # Try to parse DATABASE_URL first, fall back to individual env vars + database_url = os.environ.get('DATABASE_URL') + if database_url: + # Parse DATABASE_URL: postgresql://user:password@host:port/dbname + from urllib.parse import urlparse + parsed = urlparse(database_url) + db_host = parsed.hostname or 'db' + db_port = parsed.port or 5432 + db_name = parsed.path.lstrip('/') or 'timetrack' + db_user = parsed.username or 'timetrack' + db_password = parsed.password or 'timetrack' + else: + db_host = os.environ.get('POSTGRES_HOST', 'db') + db_name = os.environ.get('POSTGRES_DB', 'timetrack') + db_user = os.environ.get('POSTGRES_USER', 'timetrack') + db_password = os.environ.get('POSTGRES_PASSWORD', 'timetrack') + + result = subprocess.run( + ['psql', '-h', db_host, '-U', db_user, '-d', db_name, '-f', script_path], + capture_output=True, + text=True, + env={**os.environ, 'PGPASSWORD': db_password} + ) + else: + # Run Python migration script + result = subprocess.run( + [sys.executable, script_path], + capture_output=True, + text=True + ) if result.returncode == 0: print(f"β {migration_file} completed successfully") diff --git a/models/__init__.py b/models/__init__.py index 897ca0a..209dbd7 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -26,6 +26,8 @@ from .dashboard import DashboardWidget, WidgetTemplate from .work_config import WorkConfig from .invitation import CompanyInvitation +from .note import Note, NoteVisibility, NoteLink, NoteFolder +from .note_share import NoteShare # Make all models available at package level __all__ = [ @@ -45,5 +47,6 @@ 'Announcement', 'DashboardWidget', 'WidgetTemplate', 'WorkConfig', - 'CompanyInvitation' + 'CompanyInvitation', + 'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder', 'NoteShare' ] \ No newline at end of file diff --git a/models/note.py b/models/note.py new file mode 100644 index 0000000..ac6e9f0 --- /dev/null +++ b/models/note.py @@ -0,0 +1,316 @@ +""" +Note models for markdown-based documentation and knowledge management. +Migrated from models_old.py to maintain consistency with the new modular structure. +""" + +import enum +import re +from datetime import datetime, timedelta + +from sqlalchemy import UniqueConstraint + +from . import db +from .enums import Role + + +class NoteVisibility(enum.Enum): + """Note sharing visibility levels""" + PRIVATE = "Private" + TEAM = "Team" + COMPANY = "Company" + + +class Note(db.Model): + """Markdown notes with sharing capabilities""" + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + content = db.Column(db.Text, nullable=False) # Markdown content + slug = db.Column(db.String(100), nullable=False) # URL-friendly identifier + + # Visibility and sharing + visibility = db.Column(db.Enum(NoteVisibility), nullable=False, default=NoteVisibility.PRIVATE) + + # Folder organization + folder = db.Column(db.String(100), nullable=True) # Folder path like "Work/Projects" or "Personal" + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + + # Associations + created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False) + + # Optional associations + team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) # For team-specific notes + project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True) # Link to project + task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True) # Link to task + + # Tags for organization + tags = db.Column(db.String(500)) # Comma-separated tags + + # Pin important notes + is_pinned = db.Column(db.Boolean, default=False) + + # Soft delete + is_archived = db.Column(db.Boolean, default=False) + archived_at = db.Column(db.DateTime, nullable=True) + + # Relationships + created_by = db.relationship('User', foreign_keys=[created_by_id], backref='notes') + company = db.relationship('Company', backref='notes') + team = db.relationship('Team', backref='notes') + project = db.relationship('Project', backref='notes') + task = db.relationship('Task', backref='notes') + + # Unique constraint on slug per company + __table_args__ = (db.UniqueConstraint('company_id', 'slug', name='uq_note_slug_per_company'),) + + def __repr__(self): + return f'{self.content}'
+
+ def get_frontmatter(self):
+ """Get frontmatter metadata from content"""
+ from frontmatter_utils import parse_frontmatter
+ metadata, _ = parse_frontmatter(self.content)
+ return metadata
+
+ def update_frontmatter(self):
+ """Update content with current metadata as frontmatter"""
+ from frontmatter_utils import update_frontmatter
+ metadata = {
+ 'title': self.title,
+ 'visibility': self.visibility.value.lower(),
+ 'folder': self.folder,
+ 'tags': self.get_tags_list() if self.tags else None,
+ 'project': self.project.code if self.project else None,
+ 'task_id': self.task_id,
+ 'pinned': self.is_pinned if self.is_pinned else None,
+ 'created': self.created_at.isoformat() if self.created_at else None,
+ 'updated': self.updated_at.isoformat() if self.updated_at else None,
+ 'author': self.created_by.username if self.created_by else None
+ }
+ # Remove None values
+ metadata = {k: v for k, v in metadata.items() if v is not None}
+ self.content = update_frontmatter(self.content, metadata)
+
+ def sync_from_frontmatter(self):
+ """Update model fields from frontmatter in content"""
+ from frontmatter_utils import parse_frontmatter
+ metadata, _ = parse_frontmatter(self.content)
+
+ if metadata:
+ # Update fields from frontmatter
+ if 'title' in metadata:
+ self.title = metadata['title']
+ if 'visibility' in metadata:
+ try:
+ self.visibility = NoteVisibility[metadata['visibility'].upper()]
+ except KeyError:
+ pass
+ if 'folder' in metadata:
+ self.folder = metadata['folder']
+ if 'tags' in metadata:
+ if isinstance(metadata['tags'], list):
+ self.set_tags_list(metadata['tags'])
+ elif isinstance(metadata['tags'], str):
+ self.tags = metadata['tags']
+ if 'pinned' in metadata:
+ self.is_pinned = bool(metadata['pinned'])
+
+ def create_share_link(self, expires_in_days=None, password=None, max_views=None, created_by=None):
+ """Create a public share link for this note"""
+ from .note_share import NoteShare
+ from flask import g
+
+ share = NoteShare(
+ note_id=self.id,
+ created_by_id=created_by.id if created_by else g.user.id
+ )
+
+ # Set expiration
+ if expires_in_days:
+ share.expires_at = datetime.now() + timedelta(days=expires_in_days)
+
+ # Set password
+ if password:
+ share.set_password(password)
+
+ # Set view limit
+ if max_views:
+ share.max_views = max_views
+
+ db.session.add(share)
+ return share
+
+ def get_active_shares(self):
+ """Get all active share links for this note"""
+ return [s for s in self.shares if s.is_valid()]
+
+ def get_all_shares(self):
+ """Get all share links for this note"""
+ from models.note_share import NoteShare
+ return self.shares.order_by(NoteShare.created_at.desc()).all()
+
+ def has_active_shares(self):
+ """Check if this note has any active share links"""
+ return any(s.is_valid() for s in self.shares)
+
+
+class NoteLink(db.Model):
+ """Links between notes for creating relationships"""
+ id = db.Column(db.Integer, primary_key=True)
+
+ # Source and target notes with cascade deletion
+ source_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
+ target_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
+
+ # Link metadata
+ link_type = db.Column(db.String(50), default='related') # related, parent, child, etc.
+ created_at = db.Column(db.DateTime, default=datetime.now)
+ created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
+
+ # Relationships with cascade deletion
+ source_note = db.relationship('Note', foreign_keys=[source_note_id],
+ backref=db.backref('outgoing_links', cascade='all, delete-orphan'))
+ target_note = db.relationship('Note', foreign_keys=[target_note_id],
+ backref=db.backref('incoming_links', cascade='all, delete-orphan'))
+ created_by = db.relationship('User', foreign_keys=[created_by_id])
+
+ # Unique constraint to prevent duplicate links
+ __table_args__ = (db.UniqueConstraint('source_note_id', 'target_note_id', name='uq_note_link'),)
+
+ def __repr__(self):
+ return f'{self.content}'
+
+ def get_frontmatter(self):
+ """Get frontmatter metadata from content"""
+ from frontmatter_utils import parse_frontmatter
+ metadata, _ = parse_frontmatter(self.content)
+ return metadata
+
+ def update_frontmatter(self):
+ """Update content with current metadata as frontmatter"""
+ from frontmatter_utils import update_frontmatter
+ metadata = {
+ 'title': self.title,
+ 'visibility': self.visibility.value.lower(),
+ 'folder': self.folder,
+ 'tags': self.get_tags_list() if self.tags else None,
+ 'project': self.project.code if self.project else None,
+ 'task_id': self.task_id,
+ 'pinned': self.is_pinned if self.is_pinned else None,
+ 'created': self.created_at.isoformat() if self.created_at else None,
+ 'updated': self.updated_at.isoformat() if self.updated_at else None,
+ 'author': self.created_by.username if self.created_by else None
+ }
+ # Remove None values
+ metadata = {k: v for k, v in metadata.items() if v is not None}
+ self.content = update_frontmatter(self.content, metadata)
+
+ def sync_from_frontmatter(self):
+ """Update model fields from frontmatter in content"""
+ from frontmatter_utils import parse_frontmatter
+ metadata, _ = parse_frontmatter(self.content)
+
+ if metadata:
+ # Update fields from frontmatter
+ if 'title' in metadata:
+ self.title = metadata['title']
+ if 'visibility' in metadata:
+ try:
+ self.visibility = NoteVisibility[metadata['visibility'].upper()]
+ except KeyError:
+ pass
+ if 'folder' in metadata:
+ self.folder = metadata['folder']
+ if 'tags' in metadata:
+ if isinstance(metadata['tags'], list):
+ self.set_tags_list(metadata['tags'])
+ elif isinstance(metadata['tags'], str):
+ self.tags = metadata['tags']
+ if 'pinned' in metadata:
+ self.is_pinned = bool(metadata['pinned'])
+
+
+class NoteLink(db.Model):
+ """Links between notes for creating relationships"""
+ id = db.Column(db.Integer, primary_key=True)
+
+ # Source and target notes with cascade deletion
+ source_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
+ target_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
+
+ # Link metadata
+ link_type = db.Column(db.String(50), default='related') # related, parent, child, etc.
+ created_at = db.Column(db.DateTime, default=datetime.now)
+ created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
+
+ # Relationships with cascade deletion
+ source_note = db.relationship('Note', foreign_keys=[source_note_id],
+ backref=db.backref('outgoing_links', cascade='all, delete-orphan'))
+ target_note = db.relationship('Note', foreign_keys=[target_note_id],
+ backref=db.backref('incoming_links', cascade='all, delete-orphan'))
+ created_by = db.relationship('User', foreign_keys=[created_by_id])
+
+ # Unique constraint to prevent duplicate links
+ __table_args__ = (db.UniqueConstraint('source_note_id', 'target_note_id', name='uq_note_link'),)
+
+ def __repr__(self):
+ return f'Created: {note.created_at.strftime('%B %d, %Y')}
+ {note.render_html()} + +""" + response = Response(html_content, mimetype='text/html') + response.headers['Content-Disposition'] = f'attachment; filename="{note.slug}.html"' + return response + + elif format == 'pdf': + # PDF download using weasyprint + try: + import weasyprint + + # Generate HTML first + html_content = f""" + + + +Created: {note.created_at.strftime('%B %d, %Y')}
+ {note.render_html()} + +""" + + # Create temporary file for PDF + temp_file = tempfile.NamedTemporaryFile(mode='wb', suffix='.pdf', delete=False) + + # Generate PDF + weasyprint.HTML(string=html_content).write_pdf(temp_file.name) + temp_file.close() + + # Send file + response = send_file( + temp_file.name, + mimetype='application/pdf', + as_attachment=True, + download_name=f'{note.slug}.pdf' + ) + + # Clean up temp file after sending + os.unlink(temp_file.name) + + return response + + except ImportError: + # If weasyprint is not installed, return error + abort(500, "PDF generation not available") + + else: + abort(400, "Invalid format") \ No newline at end of file diff --git a/routes/organization.py b/routes/organization.py new file mode 100644 index 0000000..badad01 --- /dev/null +++ b/routes/organization.py @@ -0,0 +1,231 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, g +from models import db, User, Team, Role, Company +from routes.auth import login_required, admin_required, company_required +from sqlalchemy import or_ + +# Create the blueprint +organization_bp = Blueprint('organization', __name__) + +@organization_bp.route('/admin/organization') +@login_required +@company_required +@admin_required +def admin_organization(): + """Comprehensive organization management interface""" + company = g.user.company + + # Get all teams and users for the company + teams = Team.query.filter_by(company_id=company.id).order_by(Team.name).all() + users = User.query.filter_by(company_id=company.id).order_by(User.username).all() + + return render_template('admin_organization.html', + title='Organization Management', + teams=teams, + users=users, + Role=Role) + +@organization_bp.route('/api/organization/teams/Track your work hours efficiently
+Select a project and task to begin tracking your work
+ + +| Date | +Time | +Project / Task | +Duration | +Break | +Notes | +Actions | +
|---|---|---|---|---|---|---|
|
+
+ {{ entry.arrival_time.strftime('%d') }}
+ {{ entry.arrival_time.strftime('%b') }}
+
+ |
+
+
+ {{ entry.arrival_time|format_time }}
+
+ {{ entry.departure_time|format_time if entry.departure_time else 'Active' }}
+
+ |
+
+
+ {% if entry.project %}
+
+ {{ entry.project.code }}
+
+ {% endif %}
+ {% if entry.task %}
+ {{ entry.task.title }}
+ {% elif entry.project %}
+ {{ entry.project.name }}
+ {% else %}
+ No project
+ {% endif %}
+
+ |
+ + + {{ entry.duration|format_duration if entry.duration is not none else 'In progress' }} + + | ++ + {{ entry.total_break_duration|format_duration if entry.total_break_duration else '-' }} + + | ++ + {{ entry.notes[:30] + '...' if entry.notes and entry.notes|length > 30 else entry.notes or '-' }} + + | +
+
+ {% if entry.departure_time and not active_entry %}
+ {% if entry.arrival_time.date() >= today %}
+
+ {% else %}
+
+ {% endif %}
+ {% endif %}
+
+
+
+ |
+
Start tracking your time to see entries here
+Managers can organize users into teams, monitor team performance, and track collective working hours. Role-based access ensures appropriate permissions for different user levels.
View detailed time entry history, team performance metrics, and individual productivity reports. Export data for payroll and project management purposes.
Customize work hour requirements, mandatory break durations, and threshold settings to match your organization's policies and labor regulations.
Automatically enforces break policies and work hour regulations to help your organization stay compliant with labor laws.
Intuitive interface requires minimal training. Start tracking time immediately without complicated setup procedures.
Grows with your organization from small teams to large enterprises. Multi-tenant architecture supports multiple companies, complex organizational structures, and unlimited growth potential.
Generate meaningful reports and analytics to optimize productivity, identify trends, and make informed business decisions.
Already have a company using {{ g.branding.app_name }}? Get your company code from your administrator and register to join your organization.
Configure your company settings and policies
@@ -26,22 +26,22 @@User accounts & permissions
-Organize company structure
+Users, teams & structure