Add folders with password sharing and email invites
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+316
@@ -0,0 +1,316 @@
|
||||
import os
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from app import db
|
||||
from app.folder_utils import (
|
||||
can_edit_folder,
|
||||
can_manage_folder_settings,
|
||||
can_view_folder,
|
||||
is_folder_unlocked,
|
||||
process_pending_invites,
|
||||
unlock_folder,
|
||||
)
|
||||
from app.models import Folder, FolderInvite, FolderMember, Photo, User
|
||||
|
||||
bp = Blueprint("folders", __name__)
|
||||
|
||||
|
||||
@bp.route("/cabinet/folders")
|
||||
@login_required
|
||||
def list_folders():
|
||||
process_pending_invites(current_user)
|
||||
owned = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).all()
|
||||
shared = (
|
||||
Folder.query.join(FolderMember)
|
||||
.filter(
|
||||
FolderMember.user_id == current_user.id,
|
||||
Folder.owner_id != current_user.id,
|
||||
)
|
||||
.order_by(Folder.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return render_template("cabinet/folders/list.html", owned_folders=owned, shared_folders=shared)
|
||||
|
||||
|
||||
@bp.route("/cabinet/folders/create", methods=["POST"])
|
||||
@login_required
|
||||
def create_folder():
|
||||
name = request.form.get("name", "").strip()
|
||||
is_private = request.form.get("is_private") == "on"
|
||||
access_password = request.form.get("access_password", "").strip()
|
||||
|
||||
if len(name) < 2:
|
||||
flash("Название папки — минимум 2 символа", "error")
|
||||
return redirect(url_for("folders.list_folders"))
|
||||
|
||||
folder = Folder(name=name, owner_id=current_user.id, is_private=is_private)
|
||||
if access_password:
|
||||
if len(access_password) < 4:
|
||||
flash("Пароль папки — минимум 4 символа", "error")
|
||||
return redirect(url_for("folders.list_folders"))
|
||||
folder.set_access_password(access_password)
|
||||
|
||||
db.session.add(folder)
|
||||
db.session.commit()
|
||||
flash(f"Папка «{folder.name}» создана", "success")
|
||||
return redirect(url_for("folders.view_folder", folder_id=folder.id))
|
||||
|
||||
|
||||
@bp.route("/cabinet/folders/<int:folder_id>")
|
||||
@login_required
|
||||
def view_folder(folder_id):
|
||||
folder = Folder.query.get_or_404(folder_id)
|
||||
if not can_view_folder(folder):
|
||||
if folder.has_password:
|
||||
return redirect(url_for("folders.folder_password", folder_id=folder.id))
|
||||
abort(403)
|
||||
|
||||
photos = folder.photos.order_by(Photo.created_at.desc()).all()
|
||||
can_edit = can_edit_folder(folder)
|
||||
return render_template(
|
||||
"cabinet/folders/view.html",
|
||||
folder=folder,
|
||||
photos=photos,
|
||||
can_edit=can_edit,
|
||||
share_url=_share_url(folder),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/cabinet/folders/<int:folder_id>/password", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def folder_password(folder_id):
|
||||
folder = Folder.query.get_or_404(folder_id)
|
||||
if is_folder_owner_or_member(folder):
|
||||
return redirect(url_for("folders.view_folder", folder_id=folder.id))
|
||||
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password", "")
|
||||
if folder.check_access_password(password):
|
||||
unlock_folder(folder.id)
|
||||
flash("Доступ к папке открыт", "success")
|
||||
return redirect(url_for("folders.view_folder", folder_id=folder.id))
|
||||
flash("Неверный пароль", "error")
|
||||
|
||||
return render_template("cabinet/folders/password.html", folder=folder)
|
||||
|
||||
|
||||
@bp.route("/cabinet/folders/<int:folder_id>/settings", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def folder_settings(folder_id):
|
||||
folder = Folder.query.get_or_404(folder_id)
|
||||
if not can_manage_folder_settings(folder):
|
||||
abort(403)
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action", "save")
|
||||
|
||||
if action == "regenerate_link":
|
||||
folder.regenerate_share_token()
|
||||
db.session.commit()
|
||||
flash("Ссылка для sharing обновлена", "success")
|
||||
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||
|
||||
if action == "delete":
|
||||
_delete_folder(folder)
|
||||
flash(f"Папка «{folder.name}» удалена", "success")
|
||||
return redirect(url_for("folders.list_folders"))
|
||||
|
||||
name = request.form.get("name", "").strip()
|
||||
is_private = request.form.get("is_private") == "on"
|
||||
access_password = request.form.get("access_password", "").strip()
|
||||
remove_password = request.form.get("remove_password") == "on"
|
||||
|
||||
if len(name) < 2:
|
||||
flash("Название папки — минимум 2 символа", "error")
|
||||
else:
|
||||
folder.name = name
|
||||
folder.is_private = is_private
|
||||
if remove_password:
|
||||
folder.set_access_password(None)
|
||||
elif access_password:
|
||||
if len(access_password) < 4:
|
||||
flash("Пароль папки — минимум 4 символа", "error")
|
||||
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||
folder.set_access_password(access_password)
|
||||
db.session.commit()
|
||||
flash("Настройки папки сохранены", "success")
|
||||
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||
|
||||
members = FolderMember.query.filter_by(folder_id=folder.id).all()
|
||||
invites = FolderInvite.query.filter_by(folder_id=folder.id).all()
|
||||
return render_template(
|
||||
"cabinet/folders/settings.html",
|
||||
folder=folder,
|
||||
members=members,
|
||||
invites=invites,
|
||||
share_url=_share_url(folder),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/cabinet/folders/<int:folder_id>/invite", methods=["POST"])
|
||||
@login_required
|
||||
def invite_member(folder_id):
|
||||
folder = Folder.query.get_or_404(folder_id)
|
||||
if not can_manage_folder_settings(folder):
|
||||
abort(403)
|
||||
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
role = request.form.get("role", "viewer")
|
||||
if role not in ("viewer", "editor"):
|
||||
role = "viewer"
|
||||
|
||||
if not email or "@" not in email:
|
||||
flash("Укажите корректный email", "error")
|
||||
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if user:
|
||||
if user.id == folder.owner_id:
|
||||
flash("Владелец папки уже имеет доступ", "error")
|
||||
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||
|
||||
existing = FolderMember.query.filter_by(folder_id=folder.id, user_id=user.id).first()
|
||||
if existing:
|
||||
existing.role = role
|
||||
flash(f"Пользователь {user.username} уже в папке — роль обновлена", "success")
|
||||
else:
|
||||
db.session.add(
|
||||
FolderMember(
|
||||
folder_id=folder.id,
|
||||
user_id=user.id,
|
||||
role=role,
|
||||
added_by_id=current_user.id,
|
||||
)
|
||||
)
|
||||
FolderInvite.query.filter_by(folder_id=folder.id, email=email).delete()
|
||||
flash(f"Пользователь {user.username} добавлен в папку", "success")
|
||||
else:
|
||||
invite = FolderInvite.query.filter_by(folder_id=folder.id, email=email).first()
|
||||
if invite:
|
||||
invite.role = role
|
||||
flash("Приглашение обновлено", "success")
|
||||
else:
|
||||
db.session.add(
|
||||
FolderInvite(
|
||||
folder_id=folder.id,
|
||||
email=email,
|
||||
role=role,
|
||||
invited_by_id=current_user.id,
|
||||
)
|
||||
)
|
||||
flash(f"Приглашение отправлено на {email}. Доступ откроется после регистрации.", "success")
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||
|
||||
|
||||
@bp.route("/cabinet/folders/<int:folder_id>/members/<int:user_id>/remove", methods=["POST"])
|
||||
@login_required
|
||||
def remove_member(folder_id, user_id):
|
||||
folder = Folder.query.get_or_404(folder_id)
|
||||
if not can_manage_folder_settings(folder):
|
||||
abort(403)
|
||||
|
||||
member = FolderMember.query.filter_by(folder_id=folder.id, user_id=user_id).first_or_404()
|
||||
db.session.delete(member)
|
||||
db.session.commit()
|
||||
flash("Пользователь удалён из папки", "success")
|
||||
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||
|
||||
|
||||
@bp.route("/cabinet/folders/<int:folder_id>/invites/<int:invite_id>/remove", methods=["POST"])
|
||||
@login_required
|
||||
def remove_invite(folder_id, invite_id):
|
||||
folder = Folder.query.get_or_404(folder_id)
|
||||
if not can_manage_folder_settings(folder):
|
||||
abort(403)
|
||||
|
||||
invite = FolderInvite.query.filter_by(folder_id=folder.id, id=invite_id).first_or_404()
|
||||
db.session.delete(invite)
|
||||
db.session.commit()
|
||||
flash("Приглашение отменено", "success")
|
||||
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||
|
||||
|
||||
@bp.route("/share/f/<share_token>", methods=["GET", "POST"])
|
||||
def share_folder(share_token):
|
||||
folder = Folder.query.filter_by(share_token=share_token).first_or_404()
|
||||
|
||||
if current_user.is_authenticated:
|
||||
process_pending_invites(current_user)
|
||||
|
||||
if is_folder_owner_or_member(folder) or (can_view_folder(folder) and not folder.has_password):
|
||||
return _render_share_folder(folder)
|
||||
|
||||
if folder.has_password and not is_folder_unlocked(folder):
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password", "")
|
||||
if folder.check_access_password(password):
|
||||
unlock_folder(folder.id)
|
||||
flash("Доступ к папке открыт", "success")
|
||||
return redirect(url_for("folders.share_folder", share_token=share_token))
|
||||
flash("Неверный пароль", "error")
|
||||
return render_template("share/password.html", folder=folder, share_token=share_token)
|
||||
|
||||
return _render_share_folder(folder)
|
||||
|
||||
|
||||
def _render_share_folder(folder):
|
||||
photos = folder.photos.order_by(Photo.created_at.desc()).all()
|
||||
return render_template(
|
||||
"share/folder.html",
|
||||
folder=folder,
|
||||
photos=photos,
|
||||
can_edit=can_edit_folder(folder),
|
||||
share_url=_share_url(folder),
|
||||
)
|
||||
|
||||
|
||||
def _share_url(folder):
|
||||
return url_for("folders.share_folder", share_token=folder.share_token, _external=True)
|
||||
|
||||
|
||||
def is_folder_owner_or_member(folder):
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
if folder.owner_id == current_user.id:
|
||||
return True
|
||||
return FolderMember.query.filter_by(folder_id=folder.id, user_id=current_user.id).first() is not None
|
||||
|
||||
|
||||
def _delete_folder(folder):
|
||||
upload_dir = None
|
||||
for photo in folder.photos.all():
|
||||
if upload_dir is None:
|
||||
from flask import current_app
|
||||
upload_dir = current_app.config["UPLOAD_FOLDER"]
|
||||
filepath = os.path.join(upload_dir, photo.filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
db.session.delete(photo)
|
||||
db.session.delete(folder)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def ensure_folder_schema():
|
||||
inspector = inspect(db.engine)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if "photos" in tables:
|
||||
columns = {col["name"] for col in inspector.get_columns("photos")}
|
||||
if "folder_id" not in columns:
|
||||
db.session.execute(
|
||||
text("ALTER TABLE photos ADD COLUMN folder_id INTEGER REFERENCES folders(id)")
|
||||
)
|
||||
db.session.commit()
|
||||
Reference in New Issue
Block a user