Add Note Sharing feature.

This commit is contained in:
2025-07-08 12:40:50 +02:00
committed by Jens Luedicke
parent d2ca2905fa
commit 66d65a6ed2
11 changed files with 1505 additions and 10 deletions

View File

@@ -27,6 +27,7 @@ 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__ = [
@@ -47,5 +48,5 @@ __all__ = [
'DashboardWidget', 'WidgetTemplate',
'WorkConfig',
'CompanyInvitation',
'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder'
'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder', 'NoteShare'
]

View File

@@ -5,7 +5,7 @@ Migrated from models_old.py to maintain consistency with the new modular structu
import enum
import re
from datetime import datetime
from datetime import datetime, timedelta
from sqlalchemy import UniqueConstraint
@@ -223,6 +223,44 @@ class Note(db.Model):
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):

107
models/note_share.py Normal file
View File

@@ -0,0 +1,107 @@
from datetime import datetime, timedelta
from . import db
import secrets
import string
from werkzeug.security import generate_password_hash, check_password_hash
class NoteShare(db.Model):
"""Public sharing links for notes"""
__tablename__ = 'note_share'
id = db.Column(db.Integer, primary_key=True)
note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
# Share settings
expires_at = db.Column(db.DateTime, nullable=True) # None means no expiry
password_hash = db.Column(db.String(255), nullable=True) # Optional password protection
view_count = db.Column(db.Integer, default=0)
max_views = db.Column(db.Integer, nullable=True) # Limit number of views
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
last_accessed_at = db.Column(db.DateTime, nullable=True)
# Relationships
note = db.relationship('Note', backref=db.backref('shares', cascade='all, delete-orphan', lazy='dynamic'))
created_by = db.relationship('User', foreign_keys=[created_by_id])
def __init__(self, **kwargs):
super(NoteShare, self).__init__(**kwargs)
if not self.token:
self.token = self.generate_token()
@staticmethod
def generate_token():
"""Generate a secure random token"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(32))
def set_password(self, password):
"""Set password protection for the share"""
if password:
self.password_hash = generate_password_hash(password)
else:
self.password_hash = None
def check_password(self, password):
"""Check if the provided password is correct"""
if not self.password_hash:
return True
return check_password_hash(self.password_hash, password)
def is_valid(self):
"""Check if share link is still valid"""
# Check expiration
if self.expires_at and datetime.now() > self.expires_at:
return False
# Check view count
if self.max_views and self.view_count >= self.max_views:
return False
return True
def is_expired(self):
"""Check if the share has expired"""
if self.expires_at and datetime.now() > self.expires_at:
return True
return False
def is_view_limit_reached(self):
"""Check if view limit has been reached"""
if self.max_views and self.view_count >= self.max_views:
return True
return False
def record_access(self):
"""Record that the share was accessed"""
self.view_count += 1
self.last_accessed_at = datetime.now()
def get_share_url(self, _external=True):
"""Get the full URL for this share"""
from flask import url_for
return url_for('notes_public.view_shared_note',
token=self.token,
_external=_external)
def to_dict(self):
"""Convert share to dictionary for API responses"""
return {
'id': self.id,
'token': self.token,
'url': self.get_share_url(),
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'has_password': bool(self.password_hash),
'max_views': self.max_views,
'view_count': self.view_count,
'created_at': self.created_at.isoformat(),
'created_by': self.created_by.username,
'last_accessed_at': self.last_accessed_at.isoformat() if self.last_accessed_at else None,
'is_valid': self.is_valid(),
'is_expired': self.is_expired(),
'is_view_limit_reached': self.is_view_limit_reached()
}