491 lines
16 KiB
Python
491 lines
16 KiB
Python
# Standard library imports
|
|
from datetime import datetime, timezone
|
|
|
|
# Third-party imports
|
|
from flask import (Blueprint, abort, flash, g, jsonify, redirect,
|
|
render_template, request, url_for)
|
|
from sqlalchemy import and_, or_
|
|
|
|
# Local application imports
|
|
from models import (Note, NoteFolder, NoteLink, NoteVisibility, Project,
|
|
Task, db)
|
|
from routes.auth import company_required, login_required
|
|
|
|
# Create blueprint
|
|
notes_bp = Blueprint('notes', __name__, url_prefix='/notes')
|
|
|
|
|
|
@notes_bp.route('')
|
|
@login_required
|
|
@company_required
|
|
def notes_list():
|
|
"""List all notes with optional filtering"""
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.info("Notes list route called")
|
|
|
|
# Get filter parameters
|
|
folder_filter = request.args.get('folder', '')
|
|
tag_filter = request.args.get('tag', '')
|
|
visibility_filter = request.args.get('visibility', '')
|
|
search_query = request.args.get('search', '')
|
|
|
|
# Base query - only non-archived notes for the user's company
|
|
query = Note.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
is_archived=False
|
|
)
|
|
|
|
# Apply folder filter
|
|
if folder_filter:
|
|
query = query.filter_by(folder=folder_filter)
|
|
|
|
# Apply tag filter
|
|
if tag_filter:
|
|
query = query.filter(Note.tags.contains(tag_filter))
|
|
|
|
# Apply visibility filter
|
|
if visibility_filter:
|
|
if visibility_filter == 'private':
|
|
query = query.filter_by(visibility=NoteVisibility.PRIVATE, created_by_id=g.user.id)
|
|
elif visibility_filter == 'team':
|
|
query = query.filter(
|
|
and_(
|
|
Note.visibility == NoteVisibility.TEAM,
|
|
or_(
|
|
Note.created_by.has(team_id=g.user.team_id),
|
|
Note.created_by_id == g.user.id
|
|
)
|
|
)
|
|
)
|
|
elif visibility_filter == 'company':
|
|
query = query.filter_by(visibility=NoteVisibility.COMPANY)
|
|
else:
|
|
# Default visibility filtering - show notes user can see
|
|
query = query.filter(
|
|
or_(
|
|
# Private notes created by user
|
|
and_(Note.visibility == NoteVisibility.PRIVATE, Note.created_by_id == g.user.id),
|
|
# Team notes from user's team
|
|
and_(Note.visibility == NoteVisibility.TEAM, Note.created_by.has(team_id=g.user.team_id)),
|
|
# Company notes
|
|
Note.visibility == NoteVisibility.COMPANY
|
|
)
|
|
)
|
|
|
|
# Apply search filter
|
|
if search_query:
|
|
search_pattern = f'%{search_query}%'
|
|
query = query.filter(
|
|
or_(
|
|
Note.title.ilike(search_pattern),
|
|
Note.content.ilike(search_pattern),
|
|
Note.tags.ilike(search_pattern)
|
|
)
|
|
)
|
|
|
|
# Order by pinned first, then by updated date
|
|
notes = query.order_by(Note.is_pinned.desc(), Note.updated_at.desc()).all()
|
|
|
|
# Get all folders for the sidebar
|
|
all_folders = NoteFolder.query.filter_by(
|
|
company_id=g.user.company_id
|
|
).order_by(NoteFolder.path).all()
|
|
|
|
# Build folder tree structure
|
|
folder_tree = {}
|
|
folder_counts = {}
|
|
|
|
# Count notes per folder
|
|
folder_note_counts = db.session.query(
|
|
Note.folder, db.func.count(Note.id)
|
|
).filter_by(
|
|
company_id=g.user.company_id,
|
|
is_archived=False
|
|
).group_by(Note.folder).all()
|
|
|
|
for folder, count in folder_note_counts:
|
|
if folder:
|
|
folder_counts[folder] = count
|
|
|
|
# Build folder tree structure
|
|
for folder in all_folders:
|
|
parts = folder.path.split('/')
|
|
current = folder_tree
|
|
|
|
for i, part in enumerate(parts):
|
|
if i == len(parts) - 1:
|
|
# Leaf folder - use full path as key
|
|
current[folder.path] = {}
|
|
else:
|
|
# Navigate to parent using full path
|
|
parent_path = '/'.join(parts[:i+1])
|
|
if parent_path not in current:
|
|
current[parent_path] = {}
|
|
current = current[parent_path]
|
|
|
|
# Get all unique tags
|
|
all_notes_for_tags = Note.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
is_archived=False
|
|
).all()
|
|
|
|
all_tags = set()
|
|
tag_counts = {}
|
|
|
|
for note in all_notes_for_tags:
|
|
if note.tags:
|
|
note_tags = note.get_tags_list()
|
|
for tag in note_tags:
|
|
all_tags.add(tag)
|
|
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
|
|
|
all_tags = sorted(list(all_tags))
|
|
|
|
# Count notes by visibility
|
|
visibility_counts = {
|
|
'private': Note.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
visibility=NoteVisibility.PRIVATE,
|
|
created_by_id=g.user.id,
|
|
is_archived=False
|
|
).count(),
|
|
'team': Note.query.filter(
|
|
Note.company_id == g.user.company_id,
|
|
Note.visibility == NoteVisibility.TEAM,
|
|
Note.created_by.has(team_id=g.user.team_id),
|
|
Note.is_archived == False
|
|
).count(),
|
|
'company': Note.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
visibility=NoteVisibility.COMPANY,
|
|
is_archived=False
|
|
).count()
|
|
}
|
|
|
|
try:
|
|
logger.info(f"Rendering template with {len(notes)} notes, folder_tree type: {type(folder_tree)}")
|
|
return render_template('notes_list.html',
|
|
notes=notes,
|
|
folder_tree=folder_tree,
|
|
folder_counts=folder_counts,
|
|
all_tags=all_tags,
|
|
tag_counts=tag_counts,
|
|
visibility_counts=visibility_counts,
|
|
folder_filter=folder_filter,
|
|
tag_filter=tag_filter,
|
|
visibility_filter=visibility_filter,
|
|
search_query=search_query,
|
|
title='Notes')
|
|
except Exception as e:
|
|
logger.error(f"Error rendering notes template: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
|
|
@notes_bp.route('/new', methods=['GET', 'POST'])
|
|
@login_required
|
|
@company_required
|
|
def create_note():
|
|
"""Create a new note"""
|
|
if request.method == 'POST':
|
|
title = request.form.get('title', '').strip()
|
|
content = request.form.get('content', '').strip()
|
|
visibility = request.form.get('visibility', 'Private')
|
|
folder = request.form.get('folder', '').strip()
|
|
tags = request.form.get('tags', '').strip()
|
|
project_id = request.form.get('project_id')
|
|
task_id = request.form.get('task_id')
|
|
|
|
# Validate
|
|
if not title:
|
|
flash('Title is required', 'error')
|
|
return redirect(url_for('notes.create_note'))
|
|
|
|
if not content:
|
|
flash('Content is required', 'error')
|
|
return redirect(url_for('notes.create_note'))
|
|
|
|
# Validate visibility
|
|
try:
|
|
visibility_enum = NoteVisibility[visibility.upper()]
|
|
except KeyError:
|
|
visibility_enum = NoteVisibility.PRIVATE
|
|
|
|
# Validate project if provided
|
|
project = None
|
|
if project_id:
|
|
project = Project.query.filter_by(
|
|
id=project_id,
|
|
company_id=g.user.company_id
|
|
).first()
|
|
if not project:
|
|
flash('Invalid project selected', 'error')
|
|
return redirect(url_for('notes.create_note'))
|
|
|
|
# Validate task if provided
|
|
task = None
|
|
if task_id:
|
|
task = Task.query.filter_by(id=task_id).first()
|
|
if not task or (project and task.project_id != project.id):
|
|
flash('Invalid task selected', 'error')
|
|
return redirect(url_for('notes.create_note'))
|
|
|
|
# Create note
|
|
note = Note(
|
|
title=title,
|
|
content=content,
|
|
visibility=visibility_enum,
|
|
folder=folder if folder else None,
|
|
tags=tags if tags else None,
|
|
company_id=g.user.company_id,
|
|
created_by_id=g.user.id,
|
|
project_id=project.id if project else None,
|
|
task_id=task.id if task else None
|
|
)
|
|
|
|
db.session.add(note)
|
|
db.session.commit()
|
|
|
|
flash('Note created successfully', 'success')
|
|
return redirect(url_for('notes.view_note', slug=note.slug))
|
|
|
|
# GET request - show form
|
|
# Get folders for dropdown
|
|
folders = NoteFolder.query.filter_by(
|
|
company_id=g.user.company_id
|
|
).order_by(NoteFolder.path).all()
|
|
|
|
# Get projects for dropdown
|
|
projects = Project.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
is_archived=False
|
|
).order_by(Project.name).all()
|
|
|
|
# Get task if specified in URL
|
|
task_id = request.args.get('task_id')
|
|
task = None
|
|
if task_id:
|
|
task = Task.query.filter_by(id=task_id).first()
|
|
|
|
return render_template('note_editor.html',
|
|
folders=folders,
|
|
projects=projects,
|
|
task=task,
|
|
title='Create Note')
|
|
|
|
|
|
@notes_bp.route('/<slug>')
|
|
@login_required
|
|
@company_required
|
|
def view_note(slug):
|
|
"""View a note"""
|
|
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Check permissions
|
|
if not note.can_user_view(g.user):
|
|
abort(403)
|
|
|
|
# Get linked notes
|
|
outgoing_links = NoteLink.query.filter_by(
|
|
source_note_id=note.id
|
|
).join(
|
|
Note, NoteLink.target_note_id == Note.id
|
|
).filter(
|
|
Note.is_archived == False
|
|
).all()
|
|
|
|
incoming_links = NoteLink.query.filter_by(
|
|
target_note_id=note.id
|
|
).join(
|
|
Note, NoteLink.source_note_id == Note.id
|
|
).filter(
|
|
Note.is_archived == False
|
|
).all()
|
|
|
|
# Get linkable notes for the modal
|
|
linkable_notes = Note.query.filter(
|
|
Note.company_id == g.user.company_id,
|
|
Note.id != note.id,
|
|
Note.is_archived == False
|
|
).filter(
|
|
or_(
|
|
# User's private notes
|
|
and_(Note.visibility == NoteVisibility.PRIVATE, Note.created_by_id == g.user.id),
|
|
# Team notes
|
|
and_(Note.visibility == NoteVisibility.TEAM, Note.created_by.has(team_id=g.user.team_id)),
|
|
# Company notes
|
|
Note.visibility == NoteVisibility.COMPANY
|
|
)
|
|
).order_by(Note.title).all()
|
|
|
|
return render_template('note_view.html',
|
|
note=note,
|
|
outgoing_links=outgoing_links,
|
|
incoming_links=incoming_links,
|
|
linkable_notes=linkable_notes,
|
|
title=note.title)
|
|
|
|
|
|
@notes_bp.route('/<slug>/mindmap')
|
|
@login_required
|
|
@company_required
|
|
def view_note_mindmap(slug):
|
|
"""View a note as a mind map"""
|
|
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Check permissions
|
|
if not note.can_user_view(g.user):
|
|
abort(403)
|
|
|
|
return render_template('note_mindmap.html', note=note, title=f"{note.title} - Mind Map")
|
|
|
|
|
|
@notes_bp.route('/<slug>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
@company_required
|
|
def edit_note(slug):
|
|
"""Edit an existing note"""
|
|
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Check permissions
|
|
if not note.can_user_edit(g.user):
|
|
abort(403)
|
|
|
|
if request.method == 'POST':
|
|
title = request.form.get('title', '').strip()
|
|
content = request.form.get('content', '').strip()
|
|
visibility = request.form.get('visibility', 'Private')
|
|
folder = request.form.get('folder', '').strip()
|
|
tags = request.form.get('tags', '').strip()
|
|
project_id = request.form.get('project_id')
|
|
task_id = request.form.get('task_id')
|
|
|
|
# Validate
|
|
if not title:
|
|
flash('Title is required', 'error')
|
|
return redirect(url_for('notes.edit_note', slug=slug))
|
|
|
|
if not content:
|
|
flash('Content is required', 'error')
|
|
return redirect(url_for('notes.edit_note', slug=slug))
|
|
|
|
# Validate visibility
|
|
try:
|
|
visibility_enum = NoteVisibility[visibility.upper()]
|
|
except KeyError:
|
|
visibility_enum = NoteVisibility.PRIVATE
|
|
|
|
# Validate project if provided
|
|
project = None
|
|
if project_id:
|
|
project = Project.query.filter_by(
|
|
id=project_id,
|
|
company_id=g.user.company_id
|
|
).first()
|
|
if not project:
|
|
flash('Invalid project selected', 'error')
|
|
return redirect(url_for('notes.edit_note', slug=slug))
|
|
|
|
# Validate task if provided
|
|
task = None
|
|
if task_id:
|
|
task = Task.query.filter_by(id=task_id).first()
|
|
if not task or (project and task.project_id != project.id):
|
|
flash('Invalid task selected', 'error')
|
|
return redirect(url_for('notes.edit_note', slug=slug))
|
|
|
|
# Update note
|
|
note.title = title
|
|
note.content = content
|
|
note.visibility = visibility_enum
|
|
note.folder = folder if folder else None
|
|
note.tags = tags if tags else None
|
|
note.project_id = project.id if project else None
|
|
note.task_id = task.id if task else None
|
|
note.updated_at = datetime.now(timezone.utc)
|
|
|
|
# Update slug if title changed
|
|
note.generate_slug()
|
|
|
|
db.session.commit()
|
|
|
|
flash('Note updated successfully', 'success')
|
|
return redirect(url_for('notes.view_note', slug=note.slug))
|
|
|
|
# GET request - show form
|
|
# Get folders for dropdown
|
|
folders = NoteFolder.query.filter_by(
|
|
company_id=g.user.company_id
|
|
).order_by(NoteFolder.path).all()
|
|
|
|
# Get projects for dropdown
|
|
projects = Project.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
is_archived=False
|
|
).order_by(Project.name).all()
|
|
|
|
return render_template('note_editor.html',
|
|
note=note,
|
|
folders=folders,
|
|
projects=projects,
|
|
title=f'Edit {note.title}')
|
|
|
|
|
|
@notes_bp.route('/<slug>/delete', methods=['POST'])
|
|
@login_required
|
|
@company_required
|
|
def delete_note(slug):
|
|
"""Delete (archive) a note"""
|
|
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Check permissions
|
|
if not note.can_user_edit(g.user):
|
|
abort(403)
|
|
|
|
# Archive the note
|
|
note.is_archived = True
|
|
note.updated_at = datetime.utcnow()
|
|
|
|
db.session.commit()
|
|
|
|
flash('Note deleted successfully', 'success')
|
|
return redirect(url_for('notes.notes_list'))
|
|
|
|
|
|
@notes_bp.route('/folders')
|
|
@login_required
|
|
@company_required
|
|
def notes_folders():
|
|
"""Manage note folders"""
|
|
# Get all folders
|
|
folders = NoteFolder.query.filter_by(
|
|
company_id=g.user.company_id
|
|
).order_by(NoteFolder.path).all()
|
|
|
|
# Get note counts per folder
|
|
folder_counts = {}
|
|
folder_note_counts = db.session.query(
|
|
Note.folder, db.func.count(Note.id)
|
|
).filter_by(
|
|
company_id=g.user.company_id,
|
|
is_archived=False
|
|
).filter(Note.folder.isnot(None)).group_by(Note.folder).all()
|
|
|
|
for folder, count in folder_note_counts:
|
|
folder_counts[folder] = count
|
|
|
|
# Build folder tree
|
|
folder_tree = {}
|
|
for folder in folders:
|
|
parts = folder.path.split('/')
|
|
current = folder_tree
|
|
for part in parts:
|
|
if part not in current:
|
|
current[part] = {}
|
|
current = current[part]
|
|
|
|
return render_template('notes_folders.html',
|
|
folders=folders,
|
|
folder_tree=folder_tree,
|
|
folder_counts=folder_counts,
|
|
title='Manage Folders') |