Files
TimeTrack/models/note.py
2025-08-04 13:45:13 +02:00

406 lines
15 KiB
Python

"""
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)
# File-based note support
is_file_based = db.Column(db.Boolean, default=False) # True if note was created from uploaded file
file_path = db.Column(db.String(500), nullable=True) # Path to uploaded file
file_type = db.Column(db.String(20), nullable=True) # 'markdown', 'image', etc.
original_filename = db.Column(db.String(255), nullable=True) # Original uploaded filename
file_size = db.Column(db.Integer, nullable=True) # File size in bytes
mime_type = db.Column(db.String(100), nullable=True) # MIME type of file
# For images
image_width = db.Column(db.Integer, nullable=True)
image_height = db.Column(db.Integer, nullable=True)
# 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'<Note {self.title}>'
def generate_slug(self):
"""Generate URL-friendly slug from title and set it on the model"""
import re
# Remove special characters and convert to lowercase
slug = re.sub(r'[^\w\s-]', '', self.title.lower())
# Replace spaces with hyphens
slug = re.sub(r'[-\s]+', '-', slug)
# Remove leading/trailing hyphens
slug = slug.strip('-')
# Ensure uniqueness within company
base_slug = slug
counter = 1
while Note.query.filter_by(company_id=self.company_id, slug=slug).filter(Note.id != self.id).first():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
return slug
def can_user_view(self, user):
"""Check if user can view this note"""
# Creator can always view
if user.id == self.created_by_id:
return True
# Check company match
if user.company_id != self.company_id:
return False
# Check visibility
if self.visibility == NoteVisibility.COMPANY:
return True
elif self.visibility == NoteVisibility.TEAM:
# Check if user is in the same team
if self.team_id and user.team_id == self.team_id:
return True
# Admins can view all team notes
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
return True
return False
def can_user_edit(self, user):
"""Check if user can edit this note"""
# Creator can always edit
if user.id == self.created_by_id:
return True
# Admins can edit company notes
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] and user.company_id == self.company_id:
return True
return False
def get_tags_list(self):
"""Get tags as a list"""
if not self.tags:
return []
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
def set_tags_list(self, tags_list):
"""Set tags from a list"""
self.tags = ','.join(tags_list) if tags_list else None
def get_preview(self, length=200):
"""Get a plain text preview of the note content"""
# Strip markdown formatting for preview
import re
from frontmatter_utils import parse_frontmatter
# Extract body content without frontmatter
_, body = parse_frontmatter(self.content)
text = body
# Remove headers
text = re.sub(r'^#+\s+', '', text, flags=re.MULTILINE)
# Remove emphasis
text = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text)
text = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text)
# Remove links
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
# Remove code blocks
text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL)
text = re.sub(r'`([^`]+)`', r'\1', text)
# Clean up whitespace
text = ' '.join(text.split())
if len(text) > length:
return text[:length] + '...'
return text
def render_html(self):
"""Render markdown content to HTML with Wiki-style link support"""
try:
import markdown
from frontmatter_utils import parse_frontmatter
from wiki_links import process_wiki_links
# Extract body content without frontmatter
_, body = parse_frontmatter(self.content)
# Process Wiki-style links before markdown rendering
body = process_wiki_links(body, current_note_id=self.id)
# Use extensions for better markdown support
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc'])
return html
except ImportError:
# Fallback if markdown not installed
return f'<pre>{self.content}</pre>'
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)
@property
def is_image(self):
"""Check if this is an image note"""
return self.file_type == 'image'
@property
def is_markdown_file(self):
"""Check if this is a markdown file note"""
return self.file_type == 'markdown'
@property
def is_pdf(self):
"""Check if this is a PDF note"""
return self.file_type == 'document' and self.original_filename and self.original_filename.lower().endswith('.pdf')
@property
def file_url(self):
"""Get the URL to access the uploaded file"""
if self.file_path and self.id:
from flask import url_for
return url_for('notes_api.serve_note_file', note_id=self.id)
return None
@property
def thumbnail_url(self):
"""Get thumbnail URL for image notes"""
if self.is_image and self.file_path:
# Could implement thumbnail generation later
return self.file_url
return None
@staticmethod
def allowed_file(filename):
"""Check if file extension is allowed"""
ALLOWED_EXTENSIONS = {
'markdown': {'md', 'markdown', 'mdown', 'mkd'},
'image': {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'},
'text': {'txt'},
'document': {'pdf', 'doc', 'docx'}
}
if '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
# Check all allowed extensions
for file_type, extensions in ALLOWED_EXTENSIONS.items():
if ext in extensions:
return True
return False
@staticmethod
def get_file_type_from_filename(filename):
"""Determine file type from extension"""
if '.' not in filename:
return 'unknown'
ext = filename.rsplit('.', 1)[1].lower()
if ext in {'md', 'markdown', 'mdown', 'mkd'}:
return 'markdown'
elif ext in {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}:
return 'image'
elif ext == 'txt':
return 'text'
elif ext in {'pdf', 'doc', 'docx'}:
return 'document'
else:
return 'other'
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'<NoteLink {self.source_note_id} -> {self.target_note_id}>'
class NoteFolder(db.Model):
"""Represents a folder for organizing notes"""
id = db.Column(db.Integer, primary_key=True)
# Folder properties
name = db.Column(db.String(100), nullable=False)
path = db.Column(db.String(500), nullable=False) # Full path like "Work/Projects/Q1"
parent_path = db.Column(db.String(500), nullable=True) # Parent folder path
description = db.Column(db.Text, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
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)
# Relationships
created_by = db.relationship('User', foreign_keys=[created_by_id])
company = db.relationship('Company', foreign_keys=[company_id])
# Unique constraint to prevent duplicate paths within a company
__table_args__ = (db.UniqueConstraint('path', 'company_id', name='uq_folder_path_company'),)
def __repr__(self):
return f'<NoteFolder {self.path}>'