diff --git a/drive/api/embed.py b/drive/api/embed.py index 57223a188..9fe1b85dc 100644 --- a/drive/api/embed.py +++ b/drive/api/embed.py @@ -28,7 +28,7 @@ def get_file_content(embed_name: str, parent_entity_name: str): drive_entity = frappe.get_value( "Drive File", parent_entity_name, - ["document", "title", "mime_type", "file_size", "owner", "path", "team"], + ["document", "file_name", "mime_type", "file_size", "owner", "path", "team"], as_dict=1, ) @@ -41,7 +41,7 @@ def get_file_content(embed_name: str, parent_entity_name: str): embed = frappe._dict( path=str( Path( - get_home_folder(embed.team)["path"], + get_home_folder(embed.team)["file_url"], "embeds", embed_name, ) diff --git a/drive/api/files.py b/drive/api/files.py index b8791b28d..65c45642d 100644 --- a/drive/api/files.py +++ b/drive/api/files.py @@ -1,13 +1,13 @@ import json -import os import re -from datetime import date, timedelta +from datetime import timedelta from io import BytesIO from pathlib import Path import frappe import jwt -import magic + +# import magic import mimemapper from pypika import Order from werkzeug.utils import secure_filename, send_file @@ -21,29 +21,32 @@ get_file_type, get_home_folder, update_file_size, - get_default_team, + get_new_file_name, + validate_filename, + get_upload_path, ) from drive.utils.api import prettify_file -from drive.utils.files import FileManager +from drive.utils.files import FileManager, sanitize_url, get_s3_key, get_s3_url from .permissions import get_teams, user_has_permission +FORBIDDEN_DOWNLOAD_TYPES = ["Folder", "Link", "Document"] + @frappe.whitelist(allow_guest=True) @default_team def upload_file( team: str, total_file_size: int = 0, - last_modified: int = None, + file_modified: int = None, fullpath: str = None, parent: str = None, - transfer: int = 0, embed: int = 0, ): """ Accept chunked file contents via a multipart upload. Store the file on disk, and insert a corresponding DriveFile doc. - Works with normal uploads, transfers, and embeds. + Works with normal uploads, and embeds. :return: DriveFile doc once the entire file has been uploaded """ checks = frappe.get_hooks("validate_drive_upload") @@ -58,11 +61,11 @@ def upload_file( if not user_has_permission(parent, "upload"): frappe.throw("Ask the folder owner for upload access.", frappe.PermissionError) - team = frappe.db.get_value("Drive File", parent, "team") + team = frappe.db.get_value("File", parent, "team") if fullpath: parent = ensure_path(team, fullpath, parent) - # Support non-chunked uploads too + # Support both chunked and non-chunked uploads if frappe.form_dict.chunk_index: current_chunk = int(frappe.form_dict.chunk_index) total_chunks = int(frappe.form_dict.total_chunk_count) @@ -73,9 +76,9 @@ def upload_file( total_chunks = 1 file = frappe.request.files["file"] - title = get_new_title(file.filename, parent) if not transfer else file.filename + file_name = get_new_file_name(file.filename, parent) upload_session = frappe.form_dict.uuid - temp_path = get_upload_path(home_folder["path"], f"{upload_session}_{secure_filename(title)}") + temp_path = get_upload_path(sanitize_url(home_folder["file_url"]), f"{upload_session}_{secure_filename(file_name)}") with temp_path.open("ab") as f: f.seek(offset) f.write(file.stream.read()) @@ -92,30 +95,26 @@ def upload_file( if mime_type is None: mime_type = magic.from_buffer(open(temp_path, "rb").read(2048), mime=True) + file_type = get_file_type(mime_type) manager = FileManager() - # Create DB record - if transfer: - entity = frappe.get_doc({"doctype": "Drive Transfer", "title": title, "file_size": file_size}) - entity.insert() - entity.path = str( - Path(home_folder["path"]) / (entity.name if manager.flat else Path(".transfers") / entity.title) - ) - entity.save() - drive_file = frappe._dict(**entity.as_dict(), team=team, parent=parent) - else: - drive_file = create_drive_file( - team, - title, - parent, - mime_type, - lambda entity: manager.get_disk_path(entity, home_folder, embed), - file_size, - int(last_modified) / 1000 if last_modified else None, - ) + drive_file = create_drive_file( + team, + file_name, + parent, + file_type, + lambda file: "/" + str(manager.get_disk_path(file, home_folder, embed)), + mime_type, + file_size, + int(file_modified) / 1000 if file_modified else None, + ) # Upload and update parent folder size - manager.upload_file(temp_path, drive_file, not embed and not transfer) + manager.upload_file(temp_path, drive_file, not embed) + # Change path to be s3 compatible + if manager.s3_enabled: + drive_file.file_url = get_s3_url(get_s3_key(drive_file.file_url)) + drive_file.save() try: update_file_size(parent, file_size) @@ -123,37 +122,20 @@ def upload_file( # Find a cleaner way to handle folder sizes as multiple simultaneous uploads will break this pass - if transfer: - frappe.publish_realtime("transfer-add", {"file": drive_file}) - elif not embed: - frappe.publish_realtime("list-add", {"file": prettify_file(drive_file.as_dict())}) + frappe.publish_realtime("list-add", {"file": prettify_file(drive_file.as_dict())}) return drive_file @frappe.whitelist(allow_guest=True) def get_thumbnail(entity_name: str): - drive_file = frappe.get_value( - "Drive File", - entity_name, - [ - "is_group", - "path", - "title", - "mime_type", - "file_size", - "owner", - "team", - "document", - "name", - ], - as_dict=1, - ) - if not drive_file or drive_file.is_group or drive_file.is_link: - return - if user_has_permission(drive_file, "read") is False: + drive_file = frappe.get_cached_doc("File", entity_name) + if not drive_file or drive_file.is_folder: return + if not user_has_permission(drive_file, "read"): + frappe.throw("No permission", frappe.PermissionError) + thumbnail_data = None if frappe.cache().exists(entity_name): try: @@ -163,13 +145,13 @@ def get_thumbnail(entity_name: str): if not thumbnail_data: manager = FileManager() try: - if drive_file.mime_type.startswith("text"): + if drive_file.file_type == "Markdown": with manager.get_file(drive_file) as f: thumbnail_data = f.read()[:1000].decode("utf-8").replace("\n", "
") - elif drive_file.mime_type == "frappe_doc": - html = frappe.get_value("Drive Document", drive_file.document, "raw_content") + elif drive_file.file_type == "Document": + html = frappe.get_value("Writer Document", drive_file.details_docname, "raw_content") thumbnail_data = html[:1000] if html else "" - elif drive_file.mime_type == "frappe/slides": + elif drive_file.file_type == "Presentation": # Use this until the thumbnail method is whitelisted thumbnails = frappe.call( "slides.slides.doctype.presentation.presentation.get_slide_thumbnails", @@ -202,7 +184,7 @@ def get_thumbnail(entity_name: str): @frappe.whitelist() @default_team -def create_presentation(team: str, title: str = "Untitled", parent: str | None = None): +def create_presentation(team: str, file_name: str = "Untitled", parent: str | None = None): home_directory = get_home_folder(team) parent = parent or home_directory.name team = frappe.db.get_value("Drive File", parent, "team") @@ -214,14 +196,14 @@ def create_presentation(team: str, title: str = "Untitled", parent: str | None = try: r = frappe.call( "slides.slides.doctype.presentation.presentation.create_presentation", - title=title, + title=file_name, theme="1mjgj61m8j", ) except BaseException as e: print("Couldn't create", e) entity = create_drive_file( team, - title, + file_name, parent, "frappe/slides", lambda _: r.name, @@ -231,128 +213,39 @@ def create_presentation(team: str, title: str = "Untitled", parent: str | None = @frappe.whitelist() @default_team -def create_document_entity(team: str, title: str | None = None, parent: str | None = None): - home_directory = get_home_folder(team) - parent = parent or home_directory.name - parent_doc = frappe.get_cached_doc("Drive File", parent) - team = frappe.db.get_value("Drive File", parent, "team") - if not title: - title = get_new_title("Untitled Document", parent) - - if not user_has_permission(parent, "upload"): - frappe.throw( - "Cannot access folder due to insufficient permissions", - frappe.PermissionError, - ) - drive_doc = frappe.new_doc("Drive Document") - drive_doc.title = title - drive_doc.settings = '{"collab": true}' - drive_doc.save() - - manager = FileManager() - path = manager.create_folder( - frappe._dict( - { - "title": title, - "parent_path": Path(parent_doc.path or ""), - "team": team, - "parent_entity": parent_doc.name, - } - ), - home_directory, - ) - manager.create_folder( - frappe._dict( - { - "title": ".embeds", - "team": team, - "parent_path": path, - } - ), - home_directory, - ) - - entity = create_drive_file( - team, - title, - parent, - "frappe_doc", - lambda _: path, - document=drive_doc.name, - ) - return entity - - -def get_upload_path(team_path, file_name): - uploads_path = Path(frappe.get_site_path("private/files"), team_path, ".uploads") - if not os.path.exists(uploads_path): - uploads_path = Path(frappe.get_site_path("private/files"), team_path, ".uploads") - uploads_path.mkdir() - return uploads_path / file_name - - -@frappe.whitelist() -@default_team -def create_folder(team: str, title: str, parent: str | None = None): - """ - Create a new folder. - - :param title: Folder name - :param parent: Document-name of the parent folder. Defaults to the user directory - :raises PermissionError: If the user does not have write access to the specified parent folder - :raises FileExistsError: If a folder with the same name already exists in the specified parent folder - :return: DriveEntity doc of the new folder - """ +def create_folder(team: str, file_name: str, parent: str | None = None): home_folder = get_home_folder(team) parent = parent or home_folder.name - team = frappe.db.get_value("Drive File", parent, "team") + team = frappe.db.get_value("File", parent, "team") - parent_doc = frappe.get_doc("Drive File", parent) + parent_doc = frappe.get_doc("File", parent) if not user_has_permission(parent_doc, "upload"): frappe.throw( "You don't have permissions for this.", frappe.PermissionError, ) - entity_exists = frappe.db.exists( - { - "doctype": "Drive File", - "parent_entity": parent, - "is_group": 1, - "title": title, - "is_active": 1, - } - ) - - if entity_exists: - suggested_name = get_new_title(title, parent, folder=True) - frappe.throw( - f"Folder '{title}' already exists.\n Suggested: {suggested_name}", - FileExistsError, - ) + validate_filename(file_name, parent, "Folder", error=f"Folder '{file_name}' already exists.") manager = FileManager() path = manager.create_folder( frappe._dict( { - "title": title, + "file_name": file_name, "team": team, - "parent_path": Path(parent_doc.path or ""), + "parent_path": Path(parent_doc.file_url or ""), } ), home_folder, ) - drive_file = create_drive_file( + return create_drive_file( team, - title, + file_name, parent, - "folder", - lambda _: path, - is_group=True, + "Folder", + path, ) - return drive_file - def ensure_path(team, fullpath, parent=None): """ @@ -368,13 +261,13 @@ def ensure_path(team, fullpath, parent=None): for folder in parts[:-1]: exists = frappe.db.get_value( - "Drive File", + "File", { - "title": folder, - "is_group": 1, - "is_active": 1, + "file_name": folder, + "is_folder": 1, + "status": 1, "team": team, - "parent_entity": current_parent, + "folder": current_parent, }, "name", ) @@ -389,7 +282,7 @@ def ensure_path(team, fullpath, parent=None): @frappe.whitelist() @default_team -def create_link(team: str, title: str, link: str, parent: str | None = None): +def create_link(team: str, file_name: str, link: str, parent: str | None = None): home_folder = get_home_folder(team) parent = parent or home_folder.name @@ -398,33 +291,19 @@ def create_link(team: str, title: str, link: str, parent: str | None = None): "Cannot create link due to insufficient permissions.", frappe.PermissionError, ) - entity_exists = frappe.db.exists( - { - "doctype": "Drive File", - "parent_entity": parent, - "is_group": 1, - "title": title, - "is_active": 1, - } - ) - if entity_exists: - suggested_name = get_new_title(title, parent, folder=True) - frappe.throw( - f"File '{title}' already exists.\n Suggested: {suggested_name}", - FileExistsError, - ) + validate_filename(file_name, parent, "Link", error=f"Link '{file_name}' already exists.") drive_file = frappe.get_doc( { - "doctype": "Drive File", - "title": title, + "doctype": "File", + "file_name": file_name, "team": team, - "path": link, - "is_link": 1, - "mime_type": "link/unknown", - "_modified": frappe.utils.now_datetime(), - "parent_entity": parent, + "file_url": link, + "file_type": "Link", + "file_modified": frappe.utils.now_datetime(), + "folder": parent, + "is_drive_file": 1, } ) drive_file.insert() @@ -445,9 +324,7 @@ def create_auth_token(entity_name: str): @frappe.whitelist(allow_guest=True) -def get_file_content( - entity_name: str, trigger_download: bool = False, jwt_token: str | None = None, transfer: bool = False -): +def get_file_content(entity_name: str, trigger_download: bool = False, jwt_token: str | None = None): """ Stream file content and optionally trigger download @@ -471,54 +348,43 @@ def get_file_content( elif not user_has_permission(entity_name, "read"): raise frappe.PermissionError("You do not have permission to view this file") - trigger_download = int(trigger_download) - if transfer: - transfer = frappe.get_doc("Drive Transfer", entity_name) - drive_file = frappe._dict(**transfer.as_dict(), team=get_default_team()) - else: - drive_file = frappe.get_value( - "Drive File", - {"name": entity_name}, - [ - "is_group", - "team", - "is_link", - "path", - "title", - "mime_type", - "is_active", - "document", - ], - as_dict=1, - ) - if not drive_file or drive_file.is_group or drive_file.is_link or (not transfer and drive_file.is_active != 1): - frappe.throw("Not found", frappe.NotFound) + file = frappe.get_value( + "File", + {"name": entity_name}, + [ + "file_name", + "file_type", + "status", + "file_url", + "is_drive_file", + ], + as_dict=1, + ) + + if file.file_type == "Document" or not file.is_drive_file: + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = "/drive/w/" + file.name if file.is_drive_file else file.file_url + return + + if not file or file.file_type in FORBIDDEN_DOWNLOAD_TYPES or file.status != 1: + frappe.throw("Not found", frappe.DoesNotExistError) - return get_file_internal(drive_file, trigger_download) + return get_file_internal(file, trigger_download) def get_file_internal(file, trigger_download=0): - if ( - not trigger_download - and get_file_type(file.as_dict() if file.as_dict else dict(file)) == "Video" - and frappe.request.headers.get("Range") - ): + if not trigger_download and file.file_type == "Video" and frappe.request.headers.get("Range"): return stream_file_content(file.name) - if file.document: - frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = "/drive/w/" + file.name - return - else: - manager = FileManager() - return send_file( - manager.get_file(file), - mimetype=file.mime_type, - as_attachment=trigger_download, - conditional=True, - max_age=3600, - download_name=file.title, - environ=frappe.request.environ, - ) + + manager = FileManager() + return send_file( + manager.get_file(file), + as_attachment=trigger_download, + conditional=True, + max_age=3600, + download_name=file.file_name, + environ=frappe.request.environ, + ) @frappe.whitelist(allow_guest=True) @@ -532,9 +398,16 @@ def stream_file_content(entity_name: str): range_header = frappe.request.headers.get("Range") if not range_header: return get_file_content(entity_name) - entity = frappe.get_doc("Drive File", entity_name) + entity = frappe.get_doc("File", entity_name) if not user_has_permission(entity, "read"): raise frappe.PermissionError("You do not have permission to view this file") + + if not entity.is_drive_file: + # frappe.local.response = frappe.utils.response.download_private_file(entity.file_url) + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = entity.file_url + return + size = entity.file_size byte1, byte2 = 0, None @@ -560,7 +433,7 @@ def stream_file_content(entity_name: str): if manager.s3_enabled: data = manager.get_file(entity, f"bytes={byte1}-{byte1 + length - 1}") else: - with manager.open_file(entity.path) as f: + with manager.open_file(entity.file_url) as f: f.seek(byte1) data = f.read(length) @@ -570,7 +443,7 @@ def stream_file_content(entity_name: str): @frappe.whitelist() -def set_favourite(entities: list[str] | None = None, clear_all: bool = False): +def set_favourite(entities: list | None = None, clear_all: bool = False): """ Favouite or unfavourite DriveEntities for specified user @@ -622,10 +495,10 @@ def remove_or_restore(entity_names: list[str] | str): frappe.throw(f"Expected list but got {type(entity_names)}", ValueError) manager = FileManager() - def depth_zero_toggle_is_active(doc): + def depth_zero_toggle_status(doc): if not user_has_permission(doc, "write"): raise frappe.PermissionError("You do not have permission to remove this file") - if doc.is_active: + if doc.status: flag = 0 manager.move_to_trash(doc) else: @@ -635,14 +508,14 @@ def depth_zero_toggle_is_active(doc): manager.restore(doc) flag = 1 - doc.is_active = flag - doc._modified = frappe.utils.now_datetime() + doc.status = flag + doc.file_modified = frappe.utils.now_datetime() # Only update parent folder size if parent exists (not root level) - if doc.parent_entity: - folder_size = frappe.db.get_value("Drive File", doc.parent_entity, "file_size") or 0 + if doc.folder: + folder_size = frappe.db.get_value("File", doc.folder, "file_size") or 0 frappe.db.set_value( - "Drive File", - doc.parent_entity, + "File", + doc.folder, "file_size", folder_size + doc.file_size * (1 if flag else -1), ) @@ -650,34 +523,32 @@ def depth_zero_toggle_is_active(doc): doc.save() for entity in entity_names: - depth_zero_toggle_is_active(frappe.get_doc("Drive File", entity)) + depth_zero_toggle_status(frappe.get_doc("File", entity)) @frappe.whitelist() def delete_entities(entity_names: list[str] | None = None, clear_all: bool = False): if clear_all: - entity_names = frappe.db.get_list("Drive File", {"is_active": 0, "owner": frappe.session.user}, pluck="name") + entity_names = frappe.db.get_list("File", {"status": 0, "owner": frappe.session.user}, pluck="name") elif isinstance(entity_names, str): entity_names = json.loads(entity_names) elif not isinstance(entity_names, list) or not entity_names: frappe.throw(f"Expected non-empty list but got {type(entity_names)}", ValueError) for entity in entity_names: - frappe.get_doc("Drive File", entity).permanent_delete() + frappe.get_doc("File", entity).permanent_delete() @frappe.whitelist() def rename(entity_name: str, new_title: str): - drive_file = frappe.get_doc("Drive File", entity_name) - if not drive_file: - frappe.throw("Entity does not exist", ValueError) + drive_file = frappe.get_doc("File", entity_name) return drive_file.rename(new_title) # Will be replaced after new JS composables refactor @frappe.whitelist() def update_access(entity_name: str, method: str, **kwargs): - drive_file = frappe.get_doc("Drive File", entity_name) + drive_file = frappe.get_doc("File", entity_name) kwargs.pop("cmd") if not drive_file: frappe.throw("Entity does not exist", ValueError) @@ -698,8 +569,7 @@ def remove_recents(entity_names: list[str] | None = [], clear_all: bool = False) """ if clear_all: return frappe.db.delete("Drive Entity Log", {"user": frappe.session.user}) - - if not isinstance(entity_names, list): + elif not isinstance(entity_names, list): frappe.throw(f"Expected list but got {type(entity_names)}", ValueError) for entity in entity_names: @@ -716,36 +586,14 @@ def remove_recents(entity_names: list[str] | None = [], clear_all: bool = False) @frappe.whitelist() @default_team -def does_entity_exist(name: str | None = None, parent_entity: str | None = None, team: str | None = None): - if not parent_entity: +def does_entity_exist(name: str | None = None, folder: str | None = None, team: str | None = None): + if not folder: home_folder = get_home_folder(team) - parent_entity = home_folder.name - result = frappe.db.exists("Drive File", {"parent_entity": parent_entity, "title": name}) + folder = home_folder.name + result = frappe.db.exists("File", {"folder": folder, "file_name": name}) return result -def auto_delete_from_trash(): - days_before = (date.today() - timedelta(days=30)).isoformat() - result = frappe.db.get_all( - "Drive File", - filters={"is_active": 0, "last_modified": ["<", days_before]}, - fields=["name"], - ) - delete_entities(result) - - -def clear_deleted_files(): - days_before = (date.today() + timedelta(days=30)).isoformat() - result = frappe.db.get_all( - "Drive File", - filters={"is_active": -1, "modified": ["<", days_before]}, - fields=["name"], - ) - for entity in result: - doc = frappe.get_doc("Drive File", entity, ignore_permissions=True) - doc.delete() - - @frappe.whitelist() @default_team def move(entity_names: list[str], new_parent: str | None = None, team: str | None = None): @@ -763,12 +611,12 @@ def move(entity_names: list[str], new_parent: str | None = None, team: str | Non frappe.throw(f"Expected a non-empty list but got {type(entity_names)}", ValueError) for entity in entity_names: - doc = frappe.get_doc("Drive File", entity) + doc = frappe.get_doc("File", entity) res = doc.move(new_parent, team) - if not res["parent_entity"]: - title, personal = frappe.db.get_value("Drive Team", res["team"], ["title", "personal"]) - res["title"] = "Home" if personal else title + if not res["folder"]: + file_name, personal = frappe.db.get_value("Drive Team", res["team"], ["file_name", "personal"]) + res["file_name"] = "Home" if personal else file_name return res @@ -784,10 +632,8 @@ def search(query: str): result = frappe.db.sql( """ SELECT `tabDrive File`.name, - `tabDrive File`.title, - `tabDrive File`.is_group, - `tabDrive File`.is_link, - `tabDrive File`.mime_type, + `tabDrive File`.file_name, + `tabDrive File`.file_type, `tabDrive File`.document, `tabDrive File`.color, `tabUser`.name AS user_name, @@ -796,16 +642,14 @@ def search(query: str): FROM `tabDrive File` LEFT JOIN `tabUser` ON `tabDrive File`.`owner` = `tabUser`.`name` WHERE `tabDrive File`.team IN %(teams)s - AND `tabDrive File`.`is_active` = 1 - AND `tabDrive File`.`parent_entity` <> '' - AND MATCH(title) AGAINST (%(text)s IN BOOLEAN MODE) + AND `tabDrive File`.`status` = 1 + AND `tabDrive File`.`folder` <> '' + AND MATCH(file_name) AGAINST (%(text)s IN BOOLEAN MODE) GROUP BY `tabDrive File`.`name` """, values={"teams": teams, "text": text}, as_dict=1, ) - for r in result: - r["file_type"] = get_file_type(r) return result except Exception as e: frappe.log_error(frappe.get_traceback(), "Frappe Drive Search Error") @@ -814,54 +658,21 @@ def search(query: str): @frappe.whitelist(allow_guest=True) def translate_old_name(old_name: str): - return frappe.get_value("Drive File", {"old_name": old_name}, "name") - - -@frappe.whitelist() -def get_new_title(title: str, parent_name: str, folder: bool = False, entity: str | None = None): - """ - Returns new title for an entity if same title exists for another entity at the same level - - :param entity_title: Title of entity to be renamed (if at all) - :param parent_entity: Parent entity of entity to be renamed (if at all) - :return: String with new title - """ - entity_title, entity_ext = os.path.splitext(title) - - filters = { - "is_active": 1, - "parent_entity": parent_name, - "title": ["like", f"{entity_title}%{entity_ext}"], - } - - if folder: - filters["is_group"] = 1 - - sibling_entity_titles = frappe.db.get_list( - "Drive File", - filters=filters, - fields=["title", "name"], - ) - if ( - not sibling_entity_titles - or (sibling_entity_titles[0].name == entity) - or not any(k["title"] == title for k in sibling_entity_titles) - ): - return title - return f"{entity_title} ({len(sibling_entity_titles)}){entity_ext}" + return frappe.get_value("File", {"old_name": old_name}, "name") @frappe.whitelist(allow_guest=True) def get_entity_type(entity_name: str): + if not user_has_permission(entity_name, "read"): + frappe.throw("You do not have permission to view this file.", frappe.PermissionError) + entity = frappe.db.get_value( - "Drive File", - {"is_active": 1, "name": entity_name}, - ["team", "name", "mime_type", "is_group", "doc"], + "File", + {"status": 1, "name": entity_name}, + ["name", "file_type"], as_dict=1, ) - if entity.doc or entity.mime_type == "text/markdown": - entity["type"] = "document" - elif entity.is_group: + if entity.file_type == "Folder": entity["type"] = "folder" else: entity["type"] = "file" @@ -875,12 +686,26 @@ def get_root_folder(team: str): return get_home_folder(team) -def auto_delete_transfers(): - from frappe.utils import now_datetime, add_to_date +@frappe.whitelist(allow_guest=True) +def redirect_to_original(file_id: str): + """ + Redirect Drive attachments to original files + """ + file = frappe.get_cached_doc("File", file_id) + if not user_has_permission(file_id, "read"): + frappe.throw("You do not have permission to view this file.", frappe.PermissionError) + if not file.details_doctype == "File": + frappe.throw("This is not an attachment", ValueError) - one_hour_ago = add_to_date(now_datetime(), hours=-1) + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = "/drive/g/" + file.details_docname - transfers = frappe.get_all("Drive Transfer", filters={"creation": ["<", one_hour_ago]}, pluck="name") - for name in transfers: - frappe.delete_doc("Drive Transfer", name) +@frappe.whitelist() +def get_docs_attached_to(file_name: str): + file = frappe.get_doc("File", file_name) + return frappe.get_list( + "File", + filters={"attached_to_doctype": ["is", "set"], "file_url": file.file_url}, + fields=["attached_to_doctype", "attached_to_name"], + ) diff --git a/drive/api/integration.py b/drive/api/integration.py index 99c6f60fc..ad74002ae 100644 --- a/drive/api/integration.py +++ b/drive/api/integration.py @@ -6,7 +6,7 @@ def presentation(doc, event): if file: if event == "on_update": - frappe.get_doc("Drive File", file).rename(doc.title) + frappe.get_doc("Drive File", file).rename(doc.file_name) if event == "on_trash": print("gone, boom boom") frappe.get_doc("Drive File", file).permanent_delete() diff --git a/drive/api/list.py b/drive/api/list.py index 63a065be6..d896ad03c 100644 --- a/drive/api/list.py +++ b/drive/api/list.py @@ -1,17 +1,19 @@ import json +from collections import Counter import frappe -from pypika import Criterion, CustomFunction, Order +from pypika import Criterion, CustomFunction, Order, Query from pypika import functions as fn +from frappe.core.doctype.file.file import get_permission_query_conditions as ff_get_permission_query_conditions -from drive.utils import MIME_LIST_MAP, default_team, get_file_type, get_home_folder -from drive.utils.api import get_default_access -from .permissions import ENTITY_FIELDS, get_user_access +from drive.utils import MIME_LIST_MAP, default_team, get_home_folder, FILE_FIELDS, map_ff_to_drive_type +from drive.utils.api import get_default_access +from .permissions import get_user_access, user_has_permission DriveUser = frappe.qb.DocType("User") UserGroupMember = frappe.qb.DocType("User Group Member") -DriveFile = frappe.qb.DocType("Drive File") +DriveFile = frappe.qb.DocType("File") DrivePermission = frappe.qb.DocType("Drive Permission") Team = frappe.qb.DocType("Drive Team") TeamMember = frappe.qb.DocType("Drive Team Member") @@ -22,96 +24,307 @@ Binary = CustomFunction("BINARY", ["expression"]) +# Helper Functions for Filters +def _apply_shared_filter(query, shared_type): + """ + Filters query to show shared files based on shared_type parameter. + - "with": files shared with current user + - "public": publicly shared files + - False/None: all files with permission join + """ + user = frappe.session.user if frappe.session.user != "Guest" else "" + cond = (DrivePermission.entity == DriveFile.name) & (DrivePermission.user == user) + + if shared_type == "with": + return query.right_join(DrivePermission).on(cond) + elif shared_type == "public": + cond = (DrivePermission.entity == DriveFile.name) & (DrivePermission.user == "") + return query.right_join(DrivePermission).on(cond) + else: + return query.left_join(DrivePermission).on(cond) + + +def _apply_tags_filter(query, tag_list): + """ + Filters files by tags using OR logic (matches any tag). + """ + if not tag_list: + return query + + tag_list = json.loads(tag_list) if isinstance(tag_list, str) else tag_list + query = query.left_join(DriveEntityTag).on(DriveEntityTag.parent == DriveFile.name) + tag_list_criterion = [DriveEntityTag.tag == tag for tag in tag_list] + return query.where(Criterion.any(tag_list_criterion)) + + +def _apply_file_kinds_filter(query, file_kinds): + """ + Filters files by kind/mime type. + """ + file_kinds = json.loads(file_kinds) if isinstance(file_kinds, str) else file_kinds + if not file_kinds: + return query + + mime_types = [] + for kind in file_kinds: + mime_types.extend(MIME_LIST_MAP.get(kind, [])) + + criterion = [DriveFile.mime_type == mime_type for mime_type in mime_types] + if "Folder" in file_kinds: + criterion.append(DriveFile.is_folder == 1) + + return query.where(Criterion.any(criterion)) + + +# Data Aggregation Functions +def _get_children_count(files): + """ + Returns a dict mapping folder names to their child count. + """ + if not files: + return {} + query = ( + frappe.qb.from_(DriveFile) + .where((DriveFile.folder.isin([k["name"] for k in files])) & (DriveFile.status == 1)) + .groupby(DriveFile.folder) + .select(DriveFile.folder, fn.Count("*").as_("child_count")) + ) + return dict(query.run()) + + +def _get_share_count(team=None): + """ + Returns a dict mapping file names to their share count. + Counts shares with individual users (excludes team and public shares). + """ + query = ( + frappe.qb.from_(DriveFile) + .right_join(DrivePermission) + .on(DrivePermission.entity == DriveFile.name) + .where((DrivePermission.user != "") & (DrivePermission.user != "$TEAM")) + .select(DriveFile.name, fn.Count("*").as_("share_count")) + .groupby(DriveFile.name) + ) + return dict(query.run()) + + +def _get_public_files(): + """ + Returns a set of file names that are publicly shared. + """ + query = frappe.qb.from_(DrivePermission).where(DrivePermission.user == "").select(DrivePermission.entity) + return set(k[0] for k in query.run()) + + +def _get_team_files(): + """ + Returns a set of file names shared with team. + """ + query = frappe.qb.from_(DrivePermission).where(DrivePermission.team == 1).select(DrivePermission.entity) + return set(k[0] for k in query.run()) + + +def _get_basic_query(search): + query = frappe.qb.from_(DriveFile).where((DriveFile.status == 1) | (DriveFile.is_drive_file == 0)) + if search: + query = query.where(DriveFile.file_name.like(f"%{search}%")) + return query + + @frappe.whitelist(allow_guest=True) @default_team def files( team: str, entity_name: str | None = None, - order_by: str = "modified 1", - is_active: bool = True, - limit: int = 20, - cursor: str | None = None, - favourites_only: bool = False, - recents_only: bool = False, - shared: str | None = None, + order_by: str = "modified", + ascending: bool = True, tag_list: list[str] | str = [], file_kinds: list[str] | str = [], - folders: bool = False, - only_parent: bool = True, search: str = None, ): - field, ascending = order_by.replace("modified", "_modified").split(" ") - - all_teams = False + """ + Returns all active files in a folder. + """ if team == "all": - all_teams = True team = None + if not entity_name: + if team: + entity_name = get_home_folder(team)["name"] + else: + frappe.throw("You must provide a folder to query", ValueError) - if not entity_name and team: - entity_name = get_home_folder(team)["name"] + entity = frappe.get_doc("File", entity_name) + if team and not team == entity.team and entity.is_drive_file: + frappe.throw("Given team doesn't match the file's team", ValueError) - user = frappe.session.user if frappe.session.user != "Guest" else "" - if entity_name: - entity = frappe.get_doc("Drive File", entity_name) - # Verify that entity exists and is part of the team - if not entity: - frappe.throw( - f"Not found ({entity_name}) ", - frappe.exceptions.PageDoesNotExistError, - ) - - if not team == entity.team: - team = entity.team - - # Verify that folder is public or that they have access - user_access = get_user_access(entity, user) - - if not user_access["read"]: - frappe.throw( - f"You don't have access.", - frappe.exceptions.PermissionError, - ) - - query = frappe.qb.from_(DriveFile).where(DriveFile.is_active == is_active) - if shared: - if shared == "by" or shared == "with": - cond = (DrivePermission.entity == DriveFile.name) & ( - (DrivePermission.user if shared == "with" else DrivePermission.owner) == frappe.session.user - ) - elif shared == "public": - cond = (DrivePermission.entity == DriveFile.name) & (DrivePermission.user == "") - # if shared == "with": - # teams = get_teams() - # cond |= (DrivePermission.team == 1) & (DrivePermission.user.isin(teams)) - query = query.right_join(DrivePermission).on(cond) - else: - query = query.left_join(DrivePermission).on( - (DrivePermission.entity == DriveFile.name) & (DrivePermission.user == user) + if not user_has_permission(entity, "read"): + frappe.throw( + f"You don't have access.", + frappe.exceptions.PermissionError, ) - query = query.select(*ENTITY_FIELDS, DrivePermission.user.as_("shared_team")).where( - fn.Coalesce(DrivePermission.read, 1).as_("read") == 1 + query = _get_basic_query(search).where(DriveFile.folder == entity_name) + + return get_query_data( + query, + team=team, + tag_list=tag_list, + file_kinds=file_kinds, + entity_name=entity_name, + order_by=order_by, + ascending=ascending, + ) + + +@frappe.whitelist() +@default_team +def shared( + team: str, + shared_type: str = "with", + order_by: str = "modified", + ascending: bool = True, + tag_list: list[str] | str = [], + file_kinds: list[str] | str = [], + search: str = None, +): + """ + Returns shared files based on shared_type parameter. + - "with": files shared with current user + - "public": publicly shared files + """ + query = _get_basic_query(search) + + return get_query_data( + query, + shared_type=shared_type, + tag_list=tag_list, + file_kinds=file_kinds, + team=team, + order_by=order_by, + ascending=ascending, + ) + + +@frappe.whitelist() +@default_team +def favourites( + team: str, + order_by: str = "modified", + ascending: bool = True, + tag_list: list[str] | str = [], + file_kinds: list[str] | str = [], + search: str = None, +): + """ + Returns all files marked as favourite by the current user. + """ + query = _get_basic_query(search) + + return get_query_data( + query, + favourites_only=True, + tag_list=tag_list, + file_kinds=file_kinds, + team=team, + order_by=order_by, + ascending=ascending, ) - # Cursor pagination - if cursor: - query = query.where((Binary(DriveFile[field]) > cursor if ascending else field < cursor)).limit(limit) - # Cleaner way? - if only_parent and (not recents_only and not favourites_only and not shared): - query = query.where(DriveFile.parent_entity == entity_name) - elif not all_teams: - query = query.where((DriveFile.team == team) & (DriveFile.parent_entity != "")) +@frappe.whitelist() +@default_team +def recents( + team: str, + order_by: str = "modified", + ascending: bool = True, + tag_list: list[str] | str = [], + file_kinds: list[str] | str = [], + search: str = None, +): + """ + Returns all files marked recently by the current user. + """ + query = _get_basic_query(search) + + return get_query_data( + query, + recents_only=True, + tag_list=tag_list, + file_kinds=file_kinds, + team=team, + order_by=order_by, + ascending=ascending, + ) + + +@frappe.whitelist() +@default_team +def trash( + team: str, + order_by: str = "modified", + ascending: bool = True, + tag_list: list[str] | str = [], + file_kinds: list[str] | str = [], + search: str = None, +): + """ + Returns all deleted files (trash) for the current user. + """ + query = ( + frappe.qb.from_(DriveFile) + .where((DriveFile.status == 0) & (DriveFile.is_drive_file == 1)) + .where(DriveFile.owner == frappe.session.user) + ) + if search: + query = query.where(DriveFile.file_name.like(f"%{search}%")) + + return get_query_data( + query, + team=team, + tag_list=tag_list, + file_kinds=file_kinds, + order_by=order_by, + ascending=ascending, + ) + - # Get favourites data (only that, if applicable) +def get_query_data( + query, + favourites_only=False, + recents_only=False, + tag_list=[], + file_kinds=[], + team=None, + entity_name=None, + shared_type=None, + order_by="modified", + ascending=True, +): + """ + Runs all the necessary commands to obtain files in the structure expected by Drive frontend. + """ + # Filter by team + if team and team != "all": + query = query.where((DriveFile.team == team) | (DriveFile.team.isnull())) + + # Apply shared filter + query = _apply_shared_filter(query, shared_type) + query = query.select( + *FILE_FIELDS, + DrivePermission.user.as_("shared_team"), + ).where(fn.Coalesce(DrivePermission.read, 1).as_("read") == 1) + + # Apply favourites filter if favourites_only: query = query.right_join(DriveFavourite) else: query = query.left_join(DriveFavourite) + query = query.on((DriveFavourite.entity == DriveFile.name) & (DriveFavourite.user == frappe.session.user)).select( DriveFavourite.name.as_("is_favourite") ) + # Apply recents filter if recents_only: query = ( query.right_join(Recents) @@ -122,64 +335,27 @@ def files( query = ( query.left_join(Recents) .on((Recents.entity_name == DriveFile.name) & (Recents.user == frappe.session.user)) - .orderby(DriveFile[field], order=Order.asc if ascending else Order.desc) + .orderby(DriveFile[order_by], order=Order.asc if ascending else Order.desc) ) - if not is_active: - query = query.where(DriveFile.owner == frappe.session.user) - if search: - # escape wildcards or lower() depending on DB - query = query.where(DriveFile.title.like(f"%{search}%")) - query = query.select(Recents.last_interaction.as_("accessed")) - if tag_list: - tag_list = json.loads(tag_list) - query = query.left_join(DriveEntityTag).on(DriveEntityTag.parent == DriveFile.name) - tag_list_criterion = [DriveEntityTag.tag == tags for tags in tag_list] - query = query.where(Criterion.any(tag_list_criterion)) - - file_kinds = json.loads(file_kinds) if not isinstance(file_kinds, list) else file_kinds - if file_kinds: - mime_types = [] - for kind in file_kinds: - mime_types.extend(MIME_LIST_MAP.get(kind, [])) - criterion = [DriveFile.mime_type == mime_type for mime_type in mime_types] - if "Folder" in file_kinds: - criterion.append(DriveFile.is_group == 1) - query = query.where(Criterion.any(criterion)) - - if folders: - query = query.where(DriveFile.is_group == 1) - res = query.run(as_dict=True) - child_count_query = ( - frappe.qb.from_(DriveFile) - .where((DriveFile.team == team) & (DriveFile.is_active == 1)) - .select(DriveFile.parent_entity, fn.Count("*").as_("child_count")) - .groupby(DriveFile.parent_entity) - ) - share_query = ( - frappe.qb.from_(DriveFile) - .right_join(DrivePermission) - .on(DrivePermission.entity == DriveFile.name) - .where((DrivePermission.user != "") & (DrivePermission.user != "$TEAM")) - .select(DriveFile.name, fn.Count("*").as_("share_count")) - .groupby(DriveFile.name) - ) - public_files_query = ( - frappe.qb.from_(DrivePermission).where(DrivePermission.user == "").select(DrivePermission.entity) - ) - team_files_query = frappe.qb.from_(DrivePermission).where(DrivePermission.team == 1).select(DrivePermission.entity) - public_files = set(k[0] for k in public_files_query.run()) - team_files = set(k[0] for k in team_files_query.run()) + # Apply tag and file kind filters + query = _apply_tags_filter(query, tag_list) + query = _apply_file_kinds_filter(query, file_kinds) - children_count = dict(child_count_query.run()) - share_count = dict(share_query.run()) + res = query.run(as_dict=True) - default = get_default_access(entity_name) + # Get aggregated data + children_count = _get_children_count(res) + share_count = _get_share_count() + public_files = _get_public_files() + team_files = _get_team_files() - # Deduplicate - if shared: + default = get_default_access(entity_name) if entity_name else 0 + + # Deduplicate results + if shared_type: added = set() filtered_list = [] for r in res: @@ -188,26 +364,56 @@ def files( added.add(r["name"]) res = filtered_list - # Performance hit is wild, manually checking perms each time without cache. + # Enrich results with aggregated data and permissions for r in res: - r["children"] = children_count.get(r["name"], 0) - r["file_type"] = get_file_type(r) - - if r["name"] in public_files: - r["share_count"] = -2 - elif default > -1 and (r["name"] in team_files): - r["share_count"] = -1 - elif default == 0: - r["share_count"] = share_count.get(r["name"], default) - else: - r["share_count"] = default + r["child_count"] = children_count.get(r["name"], 0) + r["share_count"] = { + r["name"] in public_files: -2, + default > -1 and (r["name"] in team_files): -1, + default == 0: share_count.get(r["name"], default), + }.get(True, default) + + if not r["is_drive_file"]: + r["file_type"] = map_ff_to_drive_type(r) + r["modifiable"] = r["is_drive_file"] and not r["details_doctype"] == "File" + r["is_attachment"] = r["is_drive_file"] and r["details_doctype"] == "File" r |= get_user_access(r["name"]) + return res @frappe.whitelist() -def get_transfers(): - transfers = frappe.get_list( - "Drive Transfer", filters={"owner": frappe.session.user}, fields=["title", "file_size", "creation", "name"] - ) - return transfers +def get_attachments(doctype: str | None = None, docname: str | None = None): + """ + Returns all files that are attached to a document. + If either doctype or docname isn't specified, returns a list of folder-like objects + that represents the tree Doctype > Doc > Attachments. + """ + if doctype and docname: + files = frappe.get_list( + "File", filters={"attached_to_doctype": doctype, "attached_to_name": docname}, pluck="name" + ) + query = frappe.qb.from_(DriveFile).where(DriveFile.name.isin(files)) + return get_query_data(query) + + if doctype: + names = frappe.get_list("File", filters={"attached_to_doctype": doctype}, fields=["attached_to_name"]) + doctypes_set = Counter(k["attached_to_name"] for k in names) + else: + doctypes = frappe.get_list( + "File", filters={"attached_to_doctype": ["is", "set"]}, fields=["attached_to_doctype"] + ) + doctypes_set = Counter(k["attached_to_doctype"] for k in doctypes) + + return [ + { + "name": name, + "file_name": name, + "is_folder": 1, + "file_type": "Folder", + "child_count": size, + "virtual": "docname" if doctype else "doctype", + "virtual_extra": doctype, + } + for name, size in doctypes_set.items() + ] diff --git a/drive/api/notifications.py b/drive/api/notifications.py index 3d4c8bcda..4514a2fbe 100644 --- a/drive/api/notifications.py +++ b/drive/api/notifications.py @@ -4,10 +4,10 @@ def get_link(entity): - if entity.doc: + if entity.file_type == 'Document': return "/writer/w/" + entity.name - type_ = {True: "f", bool(entity.is_group): "d"} - return entity.path if entity.is_link else f"/drive/{type_.get(True)}/{entity.name}/" + type_ = {True: "f", bool(entity.is_folder): "d"} + return entity.file_url if entity.file_type == 'Link' else f"/drive/{type_.get(True)}/{entity.name}/" @frappe.whitelist() @@ -66,14 +66,14 @@ def notify_mentions(entity_name, mentions, comment=False): :param entity_name: ID of entity :param document_name: ID of document containing mentions """ - entity = frappe.get_doc("Drive File", entity_name) + entity = frappe.get_doc("File", entity_name) for mention in mentions: create_notification( frappe.session.user, mention, "Mention", entity, - f"You were mentioned in a {'comment in:' if comment else 'document:'} {entity.title}", + f"You were mentioned in a {'comment in:' if comment else 'document:'} {entity.file_name}", ) @@ -83,13 +83,13 @@ def notify_share(entity_name, docperm_name): :param entity_name: ID of entity :param document_name: ID of docshare containing share info """ - entity = frappe.get_doc("Drive File", entity_name) + entity = frappe.get_doc("File", entity_name) docshare = frappe.get_doc("Drive Permission", docperm_name) author_full_name = frappe.db.get_value("User", {"name": docshare.owner}, ["full_name"]) - entity_type = "document" if entity.doc else "folder" if entity.is_group else "file" + entity_type = "document" if entity.file_type == 'Document' else "folder" if entity.is_folder else "file" link = get_link(entity) - message = f'{author_full_name} shared a {entity_type} with you: "{entity.title}"' + message = f'{author_full_name} shared a {entity_type} with you: "{entity.file_name}"' if not frappe.db.exists("User", docshare.user): key = frappe.get_value("Drive User Invitation", {"email": docshare.user}) link = frappe.utils.get_url(f"/api/method/drive.api.product.accept_invite?key={key}&redirect={link}") @@ -105,13 +105,13 @@ def create_notification(from_user: str, to_user: str, type: str, entity: str, me if user_access.get("read") == 0: return - entity_type = "Document" if entity.doc else "Folder" if entity.is_group else "File" + entity_type = "Document" if entity.file_type == 'Document' else "Folder" if entity.is_folder else "File" details = { "from_user": from_user, "to_user": to_user, "type": type, "entity_type": entity_type, - "notif_doctype": "Drive File", + "notif_doctype": "File", "notif_doctype_name": entity.name, "message": message, } diff --git a/drive/api/permissions.py b/drive/api/permissions.py index f6f0b6208..5139f796b 100644 --- a/drive/api/permissions.py +++ b/drive/api/permissions.py @@ -1,34 +1,18 @@ -from frappe.model.document import Document -import io - import frappe -import markdown from frappe.utils import getdate -from markdown.extensions.wikilinks import WikiLinkExtension -from pypika import Field - -from drive.utils import generate_upward_path, get_default_team, get_file_type, get_valid_breadcrumbs -from drive.utils.files import FileManager +from frappe.model.document import Document +from frappe.core.doctype.file.file import has_permission as ff_has_permission + +from drive.utils import ( + generate_upward_path, + get_default_team, + get_valid_breadcrumbs, + FILE_FIELDS, + get_home_folder, + map_ff_to_drive_type, +) from drive.utils.users import mark_as_viewed -ENTITY_FIELDS = [ - "name", - "title", - "is_group", - "is_link", - "path", - Field("_modified").as_("modified"), - "creation", - "file_size", - "mime_type", - "color", - "doc", - "owner", - "parent_entity", - "team", - "allow_download", -] - NO_ACCESS = { "read": 0, @@ -54,7 +38,8 @@ def get_user_access(entity: str | Document | frappe._dict, user: str = None, tea Return the user specific permissions for an entity. Toggle `team` to check team permission. """ if isinstance(entity, str): - entity = frappe.get_cached_doc("Drive File", entity) + entity = frappe.get_cached_doc("File", entity) + access = NO_ACCESS.copy() if not user: if team: @@ -79,7 +64,7 @@ def get_user_access(entity: str | Document | frappe._dict, user: str = None, tea "read": 1, "comment": 1, "share": 0, - "upload": int(entity.is_group) and access_level, + "upload": int(entity.is_folder) and access_level, "write": int(access_level == 2 or entity.owner == user), "type": {2: "admin", 1: "user", 0: "guest"}[access_level], } @@ -136,9 +121,13 @@ def get_teams(user: str = None, details: bool = False, exclude_personal: bool = filters=[["parenttype", "=", "Drive Team"], ["user", "=", user]], ) if details: - teams_info = {team: frappe.get_doc("Drive Team", team) for team in teams} + teams_info = { + team: {**frappe.get_doc("Drive Team", team).as_dict(), "file": get_home_folder(team)["name"]} + for team in teams + } if exclude_personal: - return {t: team for t, team in teams_info.items() if not team.personal} + return {t: team for t, team in teams_info.items() if not team["personal"]} + return teams_info return teams @@ -152,18 +141,20 @@ def get_entity_with_permissions(entity_name: str): """ Return file data with permissions """ - entity = frappe.db.get_value( - "Drive File", - {"is_active": 1, "name": entity_name}, - ENTITY_FIELDS, - as_dict=1, + entity = frappe.get_all( + "File", + filters={"name": entity_name}, + or_filters={"status": 1, "is_drive_file": 0}, + fields=FILE_FIELDS, + limit=1, ) if not entity: - frappe.throw("We couldn't find what you're looking for.", {"error": frappe.NotFound}) + frappe.throw("We couldn't find what you're looking for.", frappe.DoesNotExistError) + entity = entity[0] entity["in_home"] = entity.team == get_default_team() user_access = get_user_access(entity) - if user_access.get("read") == 0: + if not user_access.get("read"): frappe.throw("You don't have access to this file.", frappe.PermissionError) owner_info = frappe.db.get_value("User", entity.owner, ["user_image", "full_name"], as_dict=True) or {} @@ -177,22 +168,7 @@ def get_entity_with_permissions(entity_name: str): ["entity as is_favourite"], ) mark_as_viewed(entity) - file_type = get_file_type(entity) - return_obj = entity | user_access | owner_info | breadcrumbs | {"is_favourite": favourite, "file_type": file_type} - if entity.mime_type == "text/markdown": - entity.document_type == "markdown" - manager = FileManager() - wrapper = io.TextIOWrapper(manager.get_file(entity)) - url_builder = ( - lambda label, base, end: f"/api/method/drive.api.docs.get_wiki_link?team={entity.team}&title={label}" - ) - with wrapper as r: - content = r.read() - return_obj["raw_content"] = markdown.markdown( - content, - output_format="html", - extensions=["extra", WikiLinkExtension(build_url=url_builder)], - ) + return_obj = entity | user_access | owner_info | breadcrumbs | {"is_favourite": favourite} default = 0 if entity_name: @@ -201,6 +177,11 @@ def get_entity_with_permissions(entity_name: str): elif get_user_access(entity_name, team=1)["read"]: default = -1 return_obj["share_count"] = default + if not entity.is_drive_file: + return_obj["file_type"] = map_ff_to_drive_type(entity) + + return_obj["modifiable"] = entity["is_drive_file"] and not entity["details_doctype"] == "File" + return_obj["is_attachment"] = entity["is_drive_file"] and entity["details_doctype"] == "File" return return_obj @@ -225,7 +206,7 @@ def get_shared_with_list(entity: str): fields=["user", "read", "write", "comment", "upload", "share"], ) - owner = frappe.db.get_value("Drive File", entity, "owner") + owner = frappe.db.get_value("File", entity, "owner") permissions.insert( 0, frappe.db.get_value("User", owner, ["user_image", "full_name", "name as user"], as_dict=True), @@ -238,26 +219,12 @@ def get_shared_with_list(entity: str): return permissions -def auto_delete_expired_perms(): - current_date = getdate() - expired_documents = frappe.get_list( - "Drive Permission", - filters=[ - ["valid_until", "is", "set"], - ["valid_until", "<", current_date], - ], - fields=["name", "valid_until"], - ) - if expired_documents: - - def batch_delete_perms(docs): - for d in docs: - frappe.delete_doc("Drive Permission", d.name) - - frappe.enqueue(batch_delete_perms, docs=expired_documents) - - def user_has_permission(doc, ptype, user=None, team=0): + if isinstance(doc, str): + doc = frappe.get_doc("File", doc) + if not doc.is_drive_file: + return ff_has_permission(doc, ptype, user) + if not user: user = frappe.session.user if user == "Administrator" or ptype == "create": @@ -271,21 +238,6 @@ def user_has_permission(doc, ptype, user=None, team=0): return bool(access[ptype]) -def user_has_permission_doc(doc, ptype, user=None): - entity = frappe.get_value("Drive File", {"document": doc.name}, "name") - if ptype == "create" or not entity: - return True - perm = user_has_permission(entity, ptype, user) - return perm - - -@frappe.whitelist() -def toggle_allow_download(entity: str, val: bool): - if not user_has_permission(entity, "share"): - frappe.throw("You don't have permission for this action.", frappe.PermissionError) - frappe.db.set_value("Drive File", entity, "allow_download", val) - - def requires(perm): def wrapped(fn): def inner(*args, **kwargs): diff --git a/drive/api/s3.py b/drive/api/s3.py new file mode 100644 index 000000000..244fcbca0 --- /dev/null +++ b/drive/api/s3.py @@ -0,0 +1,11 @@ +from drive.api.permissions import user_has_permission +import frappe +from drive.utils.files import get_s3_url + + +@frappe.whitelist(allow_guest=True) +def fetch(path: str): + file = frappe.get_doc("File", {"file_url": get_s3_url(path)}) + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = "/api/method/drive.api.files.get_file_content?entity_name=" + file.name + return diff --git a/drive/api/scripts.py b/drive/api/scripts.py index 527d6d905..7256408be 100644 --- a/drive/api/scripts.py +++ b/drive/api/scripts.py @@ -3,6 +3,8 @@ from drive.api.product import is_admin from drive.utils import create_drive_file, default_team, get_home_folder, update_file_size from drive.utils.files import FileManager +from drive.api.files import delete_entities +from datetime import date, timedelta @frappe.whitelist() @@ -38,8 +40,8 @@ def get_or_create_parent(parent_path, owner): return home_folder # Check if the parent folder exists parent = frappe.get_value( - "Drive File", - {"path": (parent_path + "/") if parent_path else "", "team": team}, + "File", + {"file_url": (parent_path + "/") if parent_path else "", "team": team}, "name", ) if parent: @@ -52,21 +54,21 @@ def get_or_create_parent(parent_path, owner): # Now create this parent folder new_parent = create_drive_file( team, - title=parent_path.strip("/").split("/")[-1], + file_name=parent_path.strip("/").split("/")[-1], parent=grandparent, mime_type="folder", entity_path=lambda _: str(parent_path) + "/", file_size=0, - is_group=True, + is_folder=True, owner=owner, ) return new_parent.name - for file, (file_size, last_modified, mime_type, actual_path) in sorted_files: + for file, (file_size, file_modified, mime_type, actual_path) in sorted_files: parent_path = str(file.parent).strip("./") parent = frappe.get_value( - "Drive File", - {"path": parent_path + "/" if parent_path else "", "team": team}, + "File", + {"file_url": parent_path + "/" if parent_path else "", "team": team}, "name", ) parent = get_or_create_parent(parent_path, frappe.session.user) @@ -78,12 +80,34 @@ def get_or_create_parent(parent_path, owner): parent, mime_type, lambda _: actual_path if mime_type != "folder" else actual_path.strip("/") + "/", - last_modified=last_modified, + file_modified=file_modified, file_size=file_size, - is_group=mime_type == "folder", + is_folder=mime_type == "folder", owner=frappe.session.user, ) ) update_file_size(parent, file_size) return files_added + + +def auto_delete_from_trash(): + days_before = (date.today() - timedelta(days=30)).isoformat() + result = frappe.db.get_all( + "File", + filters={"status": 0, "file_modified": ["<", days_before]}, + fields=["name"], + ) + delete_entities(result) + + +def clear_deleted_files(): + days_before = (date.today() + timedelta(days=30)).isoformat() + result = frappe.db.get_all( + "File", + filters={"status": -1, "modified": ["<", days_before]}, + fields=["name"], + ) + for entity in result: + doc = frappe.get_doc("File", entity, ignore_permissions=True) + doc.delete() diff --git a/drive/api/storage.py b/drive/api/storage.py index 100fa6b89..0fa03f792 100644 --- a/drive/api/storage.py +++ b/drive/api/storage.py @@ -1,11 +1,10 @@ import frappe from pypika import functions as fn -from drive.utils import default_team, get_file_type -from drive.api.permissions import user_has_permission +from drive.utils import default_team MEGA_BYTE = 1024**2 -DriveFile = frappe.qb.DocType("Drive File") +DriveFile = frappe.qb.DocType("File") @frappe.whitelist() @@ -13,34 +12,31 @@ def storage_breakdown(team: str, owned_only: bool): limit = frappe.get_value("Drive Team", team, "quota" if owned_only else "storage") * MEGA_BYTE filters = { "team": team, - "is_group": False, - "is_active": 1, + "is_folder": False, + "status": 1, "file_size": [">=", limit / 200], } if owned_only: filters["owner"] = frappe.session.user - # Get is_link because file type check requires it entities = frappe.db.get_list( - "Drive File", + "File", filters=filters, order_by="file_size desc", - fields=["name", "title", "owner", "file_size", "mime_type", "is_group", "is_link"], + fields=["name", "file_name", "owner", "file_size", "file_type"], ) - for r in entities: - r["file_type"] = get_file_type(r) query = ( frappe.qb.from_(DriveFile) - .select(DriveFile.mime_type, fn.Sum(DriveFile.file_size).as_("file_size")) - .where((DriveFile.is_group == 0) & (DriveFile.is_active == 1) & (DriveFile.team == team)) + .select(DriveFile.file_type, fn.Sum(DriveFile.file_size).as_("file_size")) + .where((DriveFile.is_folder == 0) & (DriveFile.status == 1) & (DriveFile.team == team)) ) if owned_only: query = query.where(DriveFile.owner == frappe.session.user) return { "limit": limit, - "total": query.groupby(DriveFile.mime_type).run(as_dict=True), + "total": query.groupby(DriveFile.file_type).run(as_dict=True), "entities": entities, } @@ -49,16 +45,17 @@ def storage_breakdown(team: str, owned_only: bool): @default_team def storage_bar_data(team: str | None = None, entity_name: str | None = None): if not team: - team = frappe.get_value("Drive File", entity_name, "team") + team = frappe.get_value("File", entity_name, "team") if not team: frappe.throw("Could not find team.", ValueError) + query = ( frappe.qb.from_(DriveFile) .where( (DriveFile.team == team) - & (DriveFile.is_group == 0) + & (DriveFile.is_folder == 0) & (DriveFile.owner == frappe.session.user) - & (DriveFile.is_active == 1) + & (DriveFile.status == 1) ) .select(fn.Coalesce(fn.Sum(DriveFile.file_size), 0).as_("total_size")) ) diff --git a/drive/api/tags.py b/drive/api/tags.py index 1ef608ccb..b61787f9c 100644 --- a/drive/api/tags.py +++ b/drive/api/tags.py @@ -33,7 +33,7 @@ def add_tag(entity: str, tag: str): :param entity: Entity name :param tag: Tag name """ - doc = frappe.get_doc("Drive File", entity) + doc = frappe.get_doc("File", entity) doc.append("tags", {"tag": tag}) doc.save() @@ -46,7 +46,7 @@ def get_entity_tags(entity: str): :param entity: Entity name """ - entity = frappe.get_doc("Drive File", entity) + entity = frappe.get_doc("File", entity) return map( lambda x: frappe.db.get_value("Drive Tag", x.tag, ["name", "title", "color"], as_dict=1), @@ -87,7 +87,7 @@ def edit_tag(tag: str, title: str, color: str): :param color: Color to be update with """ doc = frappe.get_doc("Drive Tag", tag) - doc.title = title + doc.file_name = title doc.color = color doc.save() @@ -100,8 +100,7 @@ def remove_tag(entity: str, tag: str = None, all: bool = False): :param entity: Entity name :param tag: Tag name """ - - entity_doc = frappe.get_doc("Drive File", entity) + entity_doc = frappe.get_doc("File", entity) for tag_doc in entity_doc.tags: if (tag_doc.tag == tag or all) and tag_doc.owner == frappe.session.user: tag_doc.delete(ignore_permissions=True) diff --git a/drive/drive/doctype/drive_comment/drive_comment.py b/drive/drive/doctype/drive_comment/drive_comment.py index 2eeb294e7..1c0dd122b 100644 --- a/drive/drive/doctype/drive_comment/drive_comment.py +++ b/drive/drive/doctype/drive_comment/drive_comment.py @@ -31,15 +31,15 @@ def after_insert(self): mention, "Mention", doc, - f"{from_owner} mentioned you in a comment in {doc.title}", + f"{from_owner} mentioned you in a comment in {doc.file_name}", ) frappe.sendmail( recipients=[mention], - subject=f"Frappe Drive - Comment in {doc.title}", + subject=f"Frappe Drive - Comment in {doc.file_name}", template="drive_comment", args={ - "message": f'{from_owner} mentioned you in a comment.', - "doc": doc.title, + "message": f"{from_owner} mentioned you in a comment.", + "doc": doc.file_name, "link": get_link(doc), }, now=True, diff --git a/drive/drive/doctype/drive_desktop_client/__init__.py b/drive/drive/doctype/drive_desktop_client/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/drive/drive/doctype/drive_desktop_client/drive_desktop_client.js b/drive/drive/doctype/drive_desktop_client/drive_desktop_client.js deleted file mode 100644 index 94990cc97..000000000 --- a/drive/drive/doctype/drive_desktop_client/drive_desktop_client.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -// frappe.ui.form.on("Drive Desktop Client", { -// refresh(frm) { - -// }, -// }); diff --git a/drive/drive/doctype/drive_desktop_client/drive_desktop_client.json b/drive/drive/doctype/drive_desktop_client/drive_desktop_client.json deleted file mode 100644 index 00969fd7c..000000000 --- a/drive/drive/doctype/drive_desktop_client/drive_desktop_client.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "actions": [], - "allow_rename": 1, - "autoname": "hash", - "creation": "2025-09-26 12:28:54.454753", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "user", - "team", - "updates" - ], - "fields": [ - { - "fieldname": "user", - "fieldtype": "Link", - "label": "User", - "options": "User" - }, - { - "fieldname": "team", - "fieldtype": "Link", - "label": "Team", - "options": "Drive Team" - }, - { - "description": "Queue for all the un-applied changes in the desktop.", - "fieldname": "updates", - "fieldtype": "Table", - "label": "Updates", - "options": "Drive File Update" - } - ], - "grid_page_length": 50, - "index_web_pages_for_search": 1, - "links": [], - "modified": "2026-02-05 15:54:03.437373", - "modified_by": "Administrator", - "module": "Drive", - "name": "Drive Desktop Client", - "naming_rule": "Random", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "row_format": "Dynamic", - "sort_field": "creation", - "sort_order": "DESC", - "states": [] -} diff --git a/drive/drive/doctype/drive_desktop_client/drive_desktop_client.py b/drive/drive/doctype/drive_desktop_client/drive_desktop_client.py deleted file mode 100644 index dec191612..000000000 --- a/drive/drive/doctype/drive_desktop_client/drive_desktop_client.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -# import frappe -from frappe.model.document import Document - - -class DriveDesktopClient(Document): - pass diff --git a/drive/drive/doctype/drive_desktop_client/test_drive_desktop_client.py b/drive/drive/doctype/drive_desktop_client/test_drive_desktop_client.py deleted file mode 100644 index 3da822070..000000000 --- a/drive/drive/doctype/drive_desktop_client/test_drive_desktop_client.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe -from frappe.tests import IntegrationTestCase - -# On IntegrationTestCase, the doctype test records and all -# link-field test record dependencies are recursively loaded -# Use these module variables to add/remove to/from that list -EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] - - -class IntegrationTestDriveDesktopClient(IntegrationTestCase): - """ - Integration tests for DriveDesktopClient. - Use this class for testing interactions between multiple components. - """ - - pass diff --git a/drive/drive/doctype/drive_disk_settings/drive_disk_settings.json b/drive/drive/doctype/drive_disk_settings/drive_disk_settings.json index f045e7eb3..85d98ac94 100644 --- a/drive/drive/doctype/drive_disk_settings/drive_disk_settings.json +++ b/drive/drive/doctype/drive_disk_settings/drive_disk_settings.json @@ -1,145 +1,153 @@ { - "actions": [], - "allow_rename": 1, - "creation": "2025-04-09 12:48:31.850910", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "general_section", - "jwt_key", - "preview_size", - "disk_section", - "flat", - "root_folder", - "team_prefix", - "thumbnail_prefix", - "s3_section", - "enabled", - "aws_key", - "aws_secret", - "bucket", - "endpoint_url", - "signature_version" - ], - "fields": [ - { - "depends_on": "enabled", - "fieldname": "aws_key", - "fieldtype": "Data", - "label": "AWS Key" - }, - { - "depends_on": "enabled", - "fieldname": "aws_secret", - "fieldtype": "Password", - "label": "AWS Secret" - }, - { - "default": "0", - "fieldname": "enabled", - "fieldtype": "Check", - "label": "Enabled" - }, - { - "depends_on": "enabled", - "fieldname": "bucket", - "fieldtype": "Data", - "label": "S3 Bucket" - }, - { - "depends_on": "enabled", - "description": "Example format: https://s3.ap-south-1.amazonaws.com", - "fieldname": "endpoint_url", - "fieldtype": "Data", - "label": "Endpoint URL" - }, - { - "default": "s3v4", - "depends_on": "enabled", - "description": "Defaults to \"s3v4\". Some providers only support \"s3\".", - "fieldname": "signature_version", - "fieldtype": "Data", - "label": "Signature Version" - }, - { - "fieldname": "s3_section", - "fieldtype": "Section Break", - "label": "S3" - }, - { - "default": "0", - "description": "Legacy option.\n
\n
\nBy default, Drive stores files the way you see it. If this option is checked, the files are store flatly, with randomized names.\n", - "fieldname": "flat", - "fieldtype": "Check", - "label": "Do not mimic folder/file structure" - }, - { - "fieldname": "general_section", - "fieldtype": "Section Break", - "label": "General" - }, - { - "default": ".thumbnails", - "fieldname": "thumbnail_prefix", - "fieldtype": "Data", - "label": "Thumbnail prefix" - }, - { - "fieldname": "jwt_key", - "fieldtype": "Data", - "label": "JWT Key", - "options": "Dangerous - if leaked, all files can be accessed." - }, - { - "default": "100", - "description": "Used to configure maximum file sizes of in-browser previews (in megabytes).", - "fieldname": "preview_size", - "fieldtype": "Int", - "in_list_view": 1, - "label": "Preview size", - "reqd": 1 - }, - { - "fieldname": "disk_section", - "fieldtype": "Section Break", - "label": "Disk" - }, - { - "fieldname": "root_folder", - "fieldtype": "Data", - "label": "Drive Folder" - }, - { - "default": "team_name", - "fieldname": "team_prefix", - "fieldtype": "Select", - "label": "Team Prefix", - "options": "team_id\nteam_name\nnone" - } - ], - "grid_page_length": 50, - "index_web_pages_for_search": 1, - "issingle": 1, - "links": [], - "modified": "2025-09-22 16:15:57.415076", - "modified_by": "Administrator", - "module": "Drive", - "name": "Drive Disk Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "row_format": "Dynamic", - "sort_field": "creation", - "sort_order": "DESC", - "states": [] + "actions": [], + "allow_rename": 1, + "creation": "2025-04-09 12:48:31.850910", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "general_section", + "jwt_key", + "preview_size", + "use_drive_for_files", + "disk_section", + "flat", + "root_folder", + "team_prefix", + "thumbnail_prefix", + "s3_section", + "enabled", + "aws_key", + "aws_secret", + "bucket", + "endpoint_url", + "signature_version" + ], + "fields": [ + { + "depends_on": "enabled", + "fieldname": "aws_key", + "fieldtype": "Data", + "label": "AWS Key" + }, + { + "depends_on": "enabled", + "fieldname": "aws_secret", + "fieldtype": "Password", + "label": "AWS Secret" + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "depends_on": "enabled", + "fieldname": "bucket", + "fieldtype": "Data", + "label": "S3 Bucket" + }, + { + "depends_on": "enabled", + "description": "Example format: https://s3.ap-south-1.amazonaws.com", + "fieldname": "endpoint_url", + "fieldtype": "Data", + "label": "Endpoint URL" + }, + { + "default": "s3v4", + "depends_on": "enabled", + "description": "Defaults to \"s3v4\". Some providers only support \"s3\".", + "fieldname": "signature_version", + "fieldtype": "Data", + "label": "Signature Version" + }, + { + "fieldname": "s3_section", + "fieldtype": "Section Break", + "label": "S3" + }, + { + "default": "0", + "description": "Legacy option.\n
\n
\nBy default, Drive stores files the way you see it. If this option is checked, the files are store flatly, with randomized names.\n", + "fieldname": "flat", + "fieldtype": "Check", + "label": "Do not mimic folder/file structure" + }, + { + "fieldname": "general_section", + "fieldtype": "Section Break", + "label": "General" + }, + { + "default": ".thumbnails", + "fieldname": "thumbnail_prefix", + "fieldtype": "Data", + "label": "Thumbnail prefix" + }, + { + "fieldname": "jwt_key", + "fieldtype": "Data", + "label": "JWT Key", + "options": "Dangerous - if leaked, all files can be accessed." + }, + { + "default": "100", + "description": "Used to configure maximum file sizes of in-browser previews (in megabytes).", + "fieldname": "preview_size", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Preview size", + "reqd": 1 + }, + { + "fieldname": "disk_section", + "fieldtype": "Section Break", + "label": "Disk" + }, + { + "fieldname": "root_folder", + "fieldtype": "Data", + "label": "Drive Folder" + }, + { + "default": "team_name", + "fieldname": "team_prefix", + "fieldtype": "Select", + "label": "Team Prefix", + "options": "team_id\nteam_name\nnone" + }, + { + "default": "0", + "description": "Make all uploaded files to be managed through Drive, instead of Framework.", + "fieldname": "use_drive_for_files", + "fieldtype": "Check", + "label": "Use Drive for file management" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-03-06 14:48:40.048946", + "modified_by": "Administrator", + "module": "Drive", + "name": "Drive Disk Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] } diff --git a/drive/drive/doctype/drive_entity_activity_log/patches/initialize_creation.py b/drive/drive/doctype/drive_entity_activity_log/patches/initialize_creation.py index 51823ad8e..15f1e7e96 100644 --- a/drive/drive/doctype/drive_entity_activity_log/patches/initialize_creation.py +++ b/drive/drive/doctype/drive_entity_activity_log/patches/initialize_creation.py @@ -8,7 +8,7 @@ def execute(): doc = frappe.new_doc("Drive Entity Activity Log") doc.entity = i.name doc.action_type = "create" - doc.message = f"Created {i.title}" + doc.message = f"Created {i.file_name}" doc.save() frappe.db.set_value("Drive Entity Activity Log", doc.name, "owner", i.owner) frappe.db.set_value("Drive Entity Activity Log", doc.name, "creation", i.creation) diff --git a/drive/drive/doctype/drive_entity_activity_log/patches/share_creation.py b/drive/drive/doctype/drive_entity_activity_log/patches/share_creation.py index f07578d54..8c71911b0 100644 --- a/drive/drive/doctype/drive_entity_activity_log/patches/share_creation.py +++ b/drive/drive/doctype/drive_entity_activity_log/patches/share_creation.py @@ -37,26 +37,26 @@ def create_activity_log(share): def update_activity_log(log, share): - title = frappe.db.get_value("Drive File", share.share_name, ["title"]) + file_name = frappe.db.get_value("Drive File", share.share_name, ["file_name"]) owner_fullname = get_fullname(share.owner) if share.everyone: log.document_field = "everyone" - message = f"{owner_fullname} shared {title} with everyone" + message = f"{owner_fullname} shared {file_name} with everyone" log.old_value = False log.new_value = True elif share.public: log.document_field = "public" - message = f"{owner_fullname} shared {title} with publicly" + message = f"{owner_fullname} shared {file_name} with publicly" log.old_value = False log.new_value = True elif share.user_doctype == "User Group": log.document_field = "User Group" - message = f"{owner_fullname} shared {title} with {share.user_name}" + message = f"{owner_fullname} shared {file_name} with {share.user_name}" log.old_value = None log.new_value = share.user_name else: log.document_field = "User" - message = f"{owner_fullname} shared {title} with {share.user_name}" + message = f"{owner_fullname} shared {file_name} with {share.user_name}" log.old_value = None log.new_value = share.user_name log.save() diff --git a/drive/drive/doctype/drive_entity_log/drive_entity_log.py b/drive/drive/doctype/drive_entity_log/drive_entity_log.py index 0443cc8dd..9ac5587b1 100644 --- a/drive/drive/doctype/drive_entity_log/drive_entity_log.py +++ b/drive/drive/doctype/drive_entity_log/drive_entity_log.py @@ -6,4 +6,13 @@ class DriveEntityLog(Document): - pass + def validate(self): + """ + Users can only create recent records for files they can access. + """ + if frappe.session.user not in ["Administrator", self.user]: + raise frappe.PermissionError("You can only create a recent record for yourself.") + + file = frappe.get_doc("File", self.entity_name) + if not user_has_permission(file, "read"): + raise frappe.PermissionError("You cannot create a recent record this file.") diff --git a/drive/drive/doctype/drive_favourite/drive_favourite.json b/drive/drive/doctype/drive_favourite/drive_favourite.json index 95061b15b..843592b07 100644 --- a/drive/drive/doctype/drive_favourite/drive_favourite.json +++ b/drive/drive/doctype/drive_favourite/drive_favourite.json @@ -22,12 +22,12 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Drive File", - "options": "Drive File", + "options": "File", "reqd": 1 } ], "links": [], - "modified": "2026-02-05 15:54:03.437373", + "modified": "2026-03-09 13:07:27.233796", "modified_by": "Administrator", "module": "Drive", "name": "Drive Favourite", diff --git a/drive/drive/doctype/drive_favourite/drive_favourite.py b/drive/drive/doctype/drive_favourite/drive_favourite.py index 82f1e4115..50967db2b 100644 --- a/drive/drive/doctype/drive_favourite/drive_favourite.py +++ b/drive/drive/doctype/drive_favourite/drive_favourite.py @@ -2,8 +2,19 @@ # For license information, please see license.txt # import frappe +from drive.api.permissions import user_has_permission from frappe.model.document import Document +import frappe class DriveFavourite(Document): - pass + def validate(self): + """ + Users can only create favourite files they can access. + """ + if frappe.session.user not in ["Administrator", self.user]: + raise frappe.PermissionError("You can only create favourites for yourself.") + + file = frappe.get_doc("File", self.entity) + if not user_has_permission(file, "read"): + raise frappe.PermissionError("You cannot favourite this file.") diff --git a/drive/drive/doctype/drive_file/drive_file.js b/drive/drive/doctype/drive_file/drive_file.js index 93a568979..98b8688df 100644 --- a/drive/drive/doctype/drive_file/drive_file.js +++ b/drive/drive/doctype/drive_file/drive_file.js @@ -1,13 +1,15 @@ // Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -frappe.ui.form.on("Drive File", { +frappe.ui.form.on('File', { refresh: (frm) => { - frm.add_custom_button(__("Permissions"), function () { - window.open("/app/drive-permission?entity=" + frm.doc.name); - }); - frm.add_custom_button(__("Open in Drive"), function () { - window.open("/drive/g/" + frm.doc.name, "_blank"); - }); + if (frm.doc.is_drive_file) { + frm.add_custom_button(__('Permissions'), function () { + window.open('/app/drive-permission?entity=' + frm.doc.name) + }) + frm.add_custom_button(__('Open in Drive'), function () { + window.open('/drive/g/' + frm.doc.name, '_blank') + }) + } }, -}); +}) diff --git a/drive/drive/doctype/drive_file/drive_file.py b/drive/drive/doctype/drive_file/drive_file.py index dd408b5c8..4708d27b4 100644 --- a/drive/drive/doctype/drive_file/drive_file.py +++ b/drive/drive/doctype/drive_file/drive_file.py @@ -9,12 +9,10 @@ from frappe.rate_limiter import rate_limit from drive.api.activity import create_new_activity_log -from drive.api.files import get_new_title from drive.api.permissions import get_user_access, user_has_permission from drive.api.product import invite_users -from drive.utils import generate_upward_path, get_home_folder, update_file_size +from drive.utils import generate_upward_path, get_home_folder, update_file_size, get_new_file_name from drive.utils.files import FileManager -from drive.utils.api import prettify_file class DriveFile(Document): @@ -43,7 +41,7 @@ def on_trash(self): frappe.db.delete("Drive Notification", {"notif_doctype_name": self.name}) frappe.db.delete("Drive Entity Activity Log", {"entity": self.name}) - if self.is_group or self.document: + if self.is_folder or self.document: for child in self.get_children(): has_write_access = user_has_permission(self, "write") child.delete(ignore_permissions=has_write_access) @@ -59,7 +57,7 @@ def after_delete(self): def on_rollback(self): if self.flags.file_created: - shutil.rmtree(self.path) if self.is_group else self.path.unlink() + shutil.rmtree(self.path) if self.is_folder else self.path.unlink() def get_children(self): """Return a generator that yields child Documents.""" @@ -102,7 +100,7 @@ def move(self, new_parent=None, new_team=None): frappe.PermissionError, ) if not ( - frappe.db.get_value("Drive File", new_parent, "is_group") + frappe.db.get_value("Drive File", new_parent, "is_folder") or frappe.db.get_value("Drive File", new_parent, "doc") ): frappe.throw( @@ -123,7 +121,7 @@ def move(self, new_parent=None, new_team=None): update_file_size(self.parent_entity, -self.file_size) update_file_size(new_parent, +self.file_size) self.parent_entity = new_parent - self.title = get_new_title(self.title, new_parent, self.is_group, self.name) + self.title = get_new_file_name(self.title, new_parent, self.is_folder, self.name) self.team = new_team @@ -153,10 +151,10 @@ def rename(self: DriveFile, new_title: str): if new_title == self.title: return self - validated_name = get_new_title(new_title, self.parent_entity, self.is_group, self.name) + validated_name = get_new_file_name(new_title, self.parent_entity, self.is_folder, self.name) if new_title != validated_name: return frappe.throw( - f"{'Folder' if self.is_group else 'File'} '{new_title}' already exists\n Try '{validated_name}' ", + f"{'Folder' if self.is_folder else 'File'} '{new_title}' already exists\n Try '{validated_name}' ", FileExistsError, ) @@ -208,7 +206,7 @@ def permanent_delete(self): frappe.throw("Not permitted", frappe.PermissionError) self.is_active = -1 - if self.is_group: + if self.is_folder: for child in self.get_children(): child.permanent_delete() self.save() diff --git a/drive/drive/doctype/drive_file_update/__init__.py b/drive/drive/doctype/drive_file_update/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/drive/drive/doctype/drive_file_update/drive_file_update.json b/drive/drive/doctype/drive_file_update/drive_file_update.json deleted file mode 100644 index ec9acdaae..000000000 --- a/drive/drive/doctype/drive_file_update/drive_file_update.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "actions": [], - "allow_rename": 1, - "creation": "2025-09-26 12:29:17.008556", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": ["type", "entity"], - "fields": [ - { - "fieldname": "type", - "fieldtype": "Select", - "label": "Type", - "options": "rename\nupload\ndelete\nmove\nedit" - }, - { - "fieldname": "entity", - "fieldtype": "Link", - "label": "Entity", - "options": "Drive File" - } - ], - "grid_page_length": 50, - "index_web_pages_for_search": 1, - "istable": 1, - "links": [], - "modified": "2025-09-26 12:29:57.626457", - "modified_by": "Administrator", - "module": "Drive", - "name": "Drive File Update", - "owner": "Administrator", - "permissions": [], - "row_format": "Dynamic", - "sort_field": "creation", - "sort_order": "DESC", - "states": [] -} diff --git a/drive/drive/doctype/drive_file_update/drive_file_update.py b/drive/drive/doctype/drive_file_update/drive_file_update.py deleted file mode 100644 index 332d289a3..000000000 --- a/drive/drive/doctype/drive_file_update/drive_file_update.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -# import frappe -from frappe.model.document import Document - - -class DriveFileUpdate(Document): - pass diff --git a/drive/drive/doctype/drive_settings/drive_settings.json b/drive/drive/doctype/drive_settings/drive_settings.json index 6674c3dd2..3e5dc39cf 100644 --- a/drive/drive/doctype/drive_settings/drive_settings.json +++ b/drive/drive/doctype/drive_settings/drive_settings.json @@ -1,85 +1,78 @@ { - "actions": [], - "allow_rename": 1, - "autoname": "field:user", - "creation": "2025-04-24 17:38:30.853130", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "user", - "single_click", - "auto_detect_links", - "writer_section", - "writer_settings" - ], - "fields": [ - { - "fieldname": "user", - "fieldtype": "Link", - "label": "User", - "options": "User", - "unique": 1 - }, - { - "default": "1", - "fieldname": "single_click", - "fieldtype": "Check", - "label": "Single Click" - }, - { - "default": "0", - "fieldname": "auto_detect_links", - "fieldtype": "Check", - "label": "Auto Detect Links" - }, - { - "fieldname": "writer_section", - "fieldtype": "Section Break", - "label": "Writer" - }, - { - "default": "{\"font_family\":\"inter\",\"font_size\":\"15\",\"line_height\":\"1.5\", \"versioning\": 5}", - "fieldname": "writer_settings", - "fieldtype": "JSON", - "label": "Writer Settings" - } - ], - "grid_page_length": 50, - "index_web_pages_for_search": 1, - "links": [], - "modified": "2026-02-05 15:54:03.437373", - "modified_by": "Administrator", - "module": "Drive", - "name": "Drive Settings", - "naming_rule": "By fieldname", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - }, - { - "create": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Drive User", - "share": 1, - "write": 1 - } - ], - "row_format": "Dynamic", - "sort_field": "creation", - "sort_order": "DESC", - "states": [] + "actions": [], + "allow_rename": 1, + "autoname": "field:user", + "creation": "2025-04-24 17:38:30.853130", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "user", + "auto_detect_links", + "writer_section", + "writer_settings" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User", + "unique": 1 + }, + { + "default": "0", + "fieldname": "auto_detect_links", + "fieldtype": "Check", + "label": "Auto Detect Links" + }, + { + "fieldname": "writer_section", + "fieldtype": "Section Break", + "label": "Writer" + }, + { + "default": "{\"font_family\":\"inter\",\"font_size\":\"15\",\"line_height\":\"1.5\", \"versioning\": 5}", + "fieldname": "writer_settings", + "fieldtype": "JSON", + "label": "Writer Settings" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-09 13:07:34.980995", + "modified_by": "Administrator", + "module": "Drive", + "name": "Drive Settings", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Drive User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] } diff --git a/drive/drive/doctype/drive_team/drive_team.py b/drive/drive/doctype/drive_team/drive_team.py index 27ae9bc8c..67502cb4e 100644 --- a/drive/drive/doctype/drive_team/drive_team.py +++ b/drive/drive/doctype/drive_team/drive_team.py @@ -15,11 +15,12 @@ def after_insert(self): """Creates the file on disk""" d = frappe.get_doc( { + "is_drive_file": 1, "name": self.name, - "doctype": "Drive File", - "title": f"Drive - {self.name}", + "doctype": "File", + "file_name": f"Drive - {self.name}", "path": "", - "is_group": 1, + "is_folder": 1, "team": self.name, } ) @@ -65,6 +66,6 @@ def on_trash(self): user_directory_path = files_dir / get_home_folder(self.name).path if user_directory_path != files_dir: shutil.rmtree(str(user_directory_path)) - frappe.db.delete("Drive File", {"team": self.name}) + frappe.db.delete("File", {"team": self.name, "is_drive_file": 1}) except: pass diff --git a/drive/drive/doctype/drive_transfer/__init__.py b/drive/drive/doctype/drive_transfer/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/drive/drive/doctype/drive_transfer/drive_transfer.js b/drive/drive/doctype/drive_transfer/drive_transfer.js deleted file mode 100644 index 7c3dbffa6..000000000 --- a/drive/drive/doctype/drive_transfer/drive_transfer.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -// frappe.ui.form.on("Drive Transfer", { -// refresh(frm) { - -// }, -// }); diff --git a/drive/drive/doctype/drive_transfer/drive_transfer.json b/drive/drive/doctype/drive_transfer/drive_transfer.json deleted file mode 100644 index eabdd58ca..000000000 --- a/drive/drive/doctype/drive_transfer/drive_transfer.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "actions": [], - "allow_rename": 1, - "creation": "2025-10-28 12:02:59.671617", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "title", - "path", - "file_size" - ], - "fields": [ - { - "fieldname": "title", - "fieldtype": "Data", - "label": "Title" - }, - { - "fieldname": "path", - "fieldtype": "Data", - "label": "Path" - }, - { - "fieldname": "file_size", - "fieldtype": "Int", - "label": "File size" - } - ], - "grid_page_length": 50, - "index_web_pages_for_search": 1, - "links": [], - "modified": "2025-10-28 12:03:25.633900", - "modified_by": "Administrator", - "module": "Drive", - "name": "Drive Transfer", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "row_format": "Dynamic", - "rows_threshold_for_grid_search": 20, - "sort_field": "creation", - "sort_order": "DESC", - "states": [] -} diff --git a/drive/drive/doctype/drive_transfer/drive_transfer.py b/drive/drive/doctype/drive_transfer/drive_transfer.py deleted file mode 100644 index d772716d2..000000000 --- a/drive/drive/doctype/drive_transfer/drive_transfer.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -import frappe -from frappe.model.document import Document -from drive.utils.files import FileManager -from drive.utils import get_default_team - - -class DriveTransfer(Document): - def after_delete(self): - if self.path: - FileManager().delete_file(frappe._dict(**self.as_dict(), team=get_default_team())) diff --git a/drive/drive/doctype/drive_transfer/test_drive_transfer.py b/drive/drive/doctype/drive_transfer/test_drive_transfer.py deleted file mode 100644 index e73379fac..000000000 --- a/drive/drive/doctype/drive_transfer/test_drive_transfer.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe -from frappe.tests import IntegrationTestCase - - -# On IntegrationTestCase, the doctype test records and all -# link-field test record dependencies are recursively loaded -# Use these module variables to add/remove to/from that list -EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] - - - -class IntegrationTestDriveTransfer(IntegrationTestCase): - """ - Integration tests for DriveTransfer. - Use this class for testing interactions between multiple components. - """ - - pass diff --git a/drive/fixtures/custom_field.json b/drive/fixtures/custom_field.json new file mode 100644 index 000000000..d4d8ee907 --- /dev/null +++ b/drive/fixtures/custom_field.json @@ -0,0 +1,524 @@ +[ + { + "alignment": "", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "File", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "section_break_nfot8", + "fieldtype": "Section Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "section_break_8", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Drive Properties", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "module": null, + "name": "File-section_break_nfot8", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "alignment": "", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": "0", + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "File", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "is_drive_file", + "fieldtype": "Check", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "section_break_nfot8", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Is Drive File", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "module": null, + "name": "File-is_drive_file", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "alignment": "", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "File", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "team", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "is_drive_file", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Drive Team", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "module": null, + "name": "File-team", + "no_copy": 0, + "non_negative": 0, + "options": "Drive Team", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "alignment": "", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "File", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "mime_type", + "fieldtype": "Data", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "drive_team", + "is_system_generated": 0, + "is_virtual": 0, + "label": "MIME Type", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "module": null, + "name": "File-mime_type", + "no_copy": 0, + "non_negative": 0, + "options": "", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "alignment": "", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": 1, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "File", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "status", + "fieldtype": "Int", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "team", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Status", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "module": null, + "name": "File-status", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "alignment": "", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "File", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "file_modified", + "fieldtype": "Datetime", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "status", + "is_system_generated": 0, + "is_virtual": 0, + "label": "File Modified", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "module": null, + "name": "File-file_modified", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "alignment": "", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "File", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "column_break_tapww", + "fieldtype": "Column Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "settings", + "is_system_generated": 0, + "is_virtual": 0, + "label": "", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "module": null, + "name": "File-column_break_tapww", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "alignment": "", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "File", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "details_doctype", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "column_break_tapww", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Details Doctype", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "module": null, + "name": "File-details_doctype", + "no_copy": 0, + "non_negative": 0, + "options": "DocType", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "alignment": "", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "button_color": "", + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "File", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "details_docname", + "fieldtype": "Dynamic Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "details_doctype", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Details Doctype", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "module": null, + "name": "File-details_docname", + "no_copy": 0, + "non_negative": 0, + "options": "details_doctype", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } +] \ No newline at end of file diff --git a/drive/hooks.py b/drive/hooks.py index a8735f62f..5792e5ee0 100644 --- a/drive/hooks.py +++ b/drive/hooks.py @@ -110,15 +110,17 @@ has_permission = { "Drive File": "drive.api.permissions.user_has_permission", - "Drive Document": "drive.api.permissions.user_has_permission_doc", } + +after_upload_file = "drive.overrides.file.after_upload_file" +# write_file = 'drive.overrides.file.write_file' # DocType Class # --------------- # Override standard doctype classes -# override_doctype_class = { -# "ToDo": "custom_app.utils.overrides.CustomToDo" -# } +override_doctype_class = { + "File": "drive.overrides.file.File" +} # Document Events # --------------- @@ -138,8 +140,7 @@ # --------------- scheduler_events = { - "daily": ["drive.api.files.auto_delete_from_trash", "drive.api.files.clear_deleted_files"], - "hourly": ["drive.api.permissions.auto_delete_expired_perms", "drive.api.files.auto_delete_transfers"], + "daily": ["drive.api.scripts.auto_delete_from_trash", "drive.api.scripts.clear_deleted_files"], } after_request = "drive.api.product.after_request" diff --git a/drive/overrides/file.py b/drive/overrides/file.py new file mode 100644 index 000000000..928e0badd --- /dev/null +++ b/drive/overrides/file.py @@ -0,0 +1,371 @@ +from pathlib import Path +import frappe +from frappe.core.doctype.file.file import File as FrappeFile +from frappe.core.doctype.file.utils import get_content_hash +from frappe.utils import get_files_path, now + +from drive.api.files import get_file_type +from drive.api.permissions import get_user_access, user_has_permission +from drive.api.product import invite_users +from drive.api.activity import create_new_activity_log + +from drive.utils import ( + generate_upward_path, + get_home_folder, + update_file_size, + get_new_file_name, + validate_filename, + get_upload_path, +) +from drive.utils.files import FileManager +import mimemapper + + +def only_for_drive_files(func): + def inner(self, *args, **kwargs): + if self.is_drive_file: + return func(self, *args, **kwargs) + else: + parent_func = getattr(super(type(self), self), func.__name__, None) + if not parent_func: + raise ValueError("This function only exists for Drive files.") + return parent_func(*args, **kwargs) + + return inner + + +class File(FrappeFile): + @only_for_drive_files + def validate(self): + pass + + @only_for_drive_files + def before_insert(self): + pass + + @only_for_drive_files + def generate_content_hash(self): + pass + + @only_for_drive_files + def get_full_path(self): + return get_files_path(self.file_url, private=True) + + @only_for_drive_files + def set_folder_name(self): + pass + + @only_for_drive_files + def autoname(self): + if getattr(self, "_name", None): + self.name = self._name + else: + self.name = frappe.generate_hash(length=10) + + @only_for_drive_files + def set_is_private(self): + self.is_private = 1 + + @only_for_drive_files + def set_file_type(self): + pass + + # Drive methods + def __update_modified(func): + """Used for functions that meaningfuly "modify" a file""" + + def decorator(self, *args, **kwargs): + # Legacy code + res = func(self, *args, **kwargs) + self.db_set("file_modified", now()) + return res + + return decorator + + @frappe.whitelist() + def share( + self, + user: str = None, + read: bool | None = None, + comment: bool | None = None, + share: bool | None = None, + upload: bool | None = None, + write: bool | None = None, + team: bool = False, + ): + if not user_has_permission(self, "share"): + frappe.throw("Not permitted to share", frappe.PermissionError) + + # Clean out existing general records + if not user or team: + self.unshare("$GENERAL") + + permission = frappe.db.get_value( + "Drive Permission", + { + "entity": self.name, + "user": user or "", + "team": team, + }, + ) + if not permission: + permission = frappe.new_doc("Drive Permission") + permission.update( + { + "user": user, + "team": team, + "entity": self.name, + } + ) + else: + permission = frappe.get_doc("Drive Permission", permission) + + # Create user + if user and not frappe.db.exists("User", user): + invite_users(user, auto=True) + + levels = [ + ["read", read], + ["comment", comment], + ["share", share], + ["upload", upload], + ["write", write], + ] + permission.update({l[0]: l[1] for l in levels if l[1] is not None}) + + permission.save(ignore_permissions=True) + + @frappe.whitelist() + def unshare(self, user: str | None = None): + if not user_has_permission(self, "share"): + frappe.throw("Not permitted to unshare", frappe.PermissionError) + + absolute_path = generate_upward_path(self.name) + for i in absolute_path: + if i["owner"] == user: + frappe.throw("User owns parent folder", frappe.PermissionError) + + if user == "$GENERAL": + perm_names = frappe.db.get_list( + "Drive Permission", + {"entity": self.name}, + or_filters={"user": "", "team": 1}, + pluck="name", + ) + for perm_name in perm_names: + frappe.delete_doc("Drive Permission", perm_name, ignore_permissions=True) + + # If overriding perms of a parent folder, write out an explicit denial + public_access = get_user_access(self, "Guest") + team_access = get_user_access(self, team=1) + user = None + if public_access["read"]: + user = "" + elif team_access["read"]: + user = team_access["team"] + + # Doesn't work as higher access "overrides" in get_user_access + if user is not None: + frappe.get_doc( + { + "doctype": "Drive Permission", + "user": user, + "entity": self.name, + "read": 0, + "comment": 0, + "share": 0, + "write": 0, + "team": bool(user), + } + ).insert() + + else: + perm_name = frappe.db.get_value( + "Drive Permission", + { + "user": user, + "entity": self.name, + }, + ) + if perm_name: + frappe.delete_doc("Drive Permission", perm_name, ignore_permissions=True) + + @__update_modified + def move(self, new_parent=None, new_team=None): + """ + Move file to a new folder. + """ + # Logic to decide new values + if new_team and not new_parent: + new_parent = new_parent or get_home_folder(new_team).name + elif new_parent and not new_team: + new_team = frappe.db.get_value("File", new_parent, "team") + elif not new_parent and not new_team: + new_team = self.team + new_parent = new_parent or get_home_folder(new_team).name + + if new_parent == self.name: + frappe.throw( + "Cannot move into itself", + ValueError, + ) + elif not user_has_permission(new_parent, "upload") or not user_has_permission(self, "write"): + frappe.throw("You don't have permission to move this file.", frappe.PermissionError) + + if not ( + frappe.db.get_value("File", new_parent, "is_folder") + # FIX: disable after redesign + or frappe.db.get_value("File", new_parent, "details_doctype") + ): + frappe.throw( + "Can only move into folders", + NotADirectoryError, + ) + + for child in self.get_children(): + if child.name == self.name or child.name == new_parent: + frappe.throw( + "Cannot move into itself", + ValueError, + ) + elif new_team != child.team: + child.move(self.name, new_team) + + if new_parent != self.folder: + update_file_size(self.folder, -self.file_size) + update_file_size(new_parent, +self.file_size) + self.folder = new_parent + self.file_name = get_new_file_name(self.file_name, new_parent, self.is_folder, self.name) + + self.team = new_team + not_in_disk = self.file_type == "Link" or not self.file_url + + # Update all the children's paths + if not self.manager.flat and not not_in_disk: + new_path = self.manager.get_disk_path(self) + self.manager.move(self, str(new_path)) + self.recursive_path_move(self.file_url, new_path) + self.file_url = new_path + self.save() + + return frappe.get_value("File", new_parent, ["file_name", "team", "name", "folder"], as_dict=True) + + def toggle_favourite(self): + existing_doc = frappe.db.exists( + { + "doctype": "Drive Favourite", + "entity": self.name, + "user": frappe.session.user, + } + ) + if existing_doc: + frappe.delete_doc("Drive Favourite", existing_doc) + return False + else: + frappe.get_doc( + { + "doctype": "Drive Favourite", + "entity": self.name, + "user": frappe.session.user, + } + ).insert() + return True + + @frappe.whitelist() + @__update_modified + def rename(self, new_file_name: str): + """ + Rename file or folder + """ + if not user_has_permission(self, "write"): + frappe.throw("You cannot rename this file", frappe.PermissionError) + + if new_file_name == self.file_name: + return self + validate_filename(new_file_name, self.folder, self.file_type) + + full_name = frappe.db.get_value("User", {"name": frappe.session.user}, ["full_name"]) + message = f"{full_name} renamed {self.file_name} to {new_file_name}" + create_new_activity_log( + entity=self.name, + activity_type="rename", + activity_message=message, + document_field="file_name", + field_old_value=self.file_name, + field_new_value=new_file_name, + ) + if len(new_file_name) > 140: + frappe.throw("Your file_name can't be more than 140 characters.") + + self.file_name = new_file_name + path = self.manager.rename(self) + if self.file_url and self.mime_type != "frappe/slides": + self.recursive_path_move(self.file_url, path) + + self.save() + + def permanent_delete(self): + write_access = user_has_permission(self, "write") + parent_write_access = user_has_permission(self.folder, "write") + if not (write_access or parent_write_access): + frappe.throw("Not permitted", frappe.PermissionError) + + self.status = -1 + if self.is_folder: + for child in self.get_children(): + child.permanent_delete() + self.save() + + # Utils + @property + def manager(self): + return FileManager() + + def recursive_path_move(self, old, new): + if new: + self.file_url = new + for child in self.get_children(): + in_disk = child.file_type != "Link" and self.file_url + if in_disk: + child.recursive_path_move(child.file_url, str(Path(new) / Path(child.file_url).relative_to(old))) + self.save() + + def get_children(self): + """Returns a generator that yields child Documents.""" + child_names = frappe.get_list(self.doctype, filters={"folder": self.name}, pluck="name") + for name in child_names: + yield frappe.get_doc(self.doctype, name) + + +def after_upload_file(doc): + if doc.is_drive_file: + return + settings = frappe.get_single("Drive Disk Settings") + if frappe.form_dict.library_file_name: + library_doc = frappe.get_doc("File", frappe.form_dict.library_file_name) + doc.is_drive_file = library_doc.is_drive_file + if doc.is_drive_file: + doc.file_type = library_doc.file_type + doc.file_size = library_doc.file_size + doc.modified = library_doc.modified + doc.details_doctype = "File" + doc.details_docname = frappe.form_dict.library_file_name + elif settings.use_drive_for_files and doc.attached_to_name: + doc.is_drive_file = 1 + content_hash = get_content_hash(doc.content) + temp_path = get_upload_path("private/files", content_hash[:6] + "-" + doc.file_name) + with temp_path.open("wb") as f: + f.write(doc.content) + + file_path = Path("private/files") / doc.attached_to_doctype / doc.attached_to_name / doc.file_name + save_folder = Path(frappe.get_site_path()) / file_path.parent + if not save_folder.exists(): + save_folder.mkdir(parents=True) + + doc.file_url = "/" + str(file_path) + doc.mime_type = mimemapper.get_mime_type(str(temp_path), native_first=False) + doc.file_type = get_file_type(doc.mime_type) + manager = FileManager() + manager.upload_file(temp_path, doc, create_thumbnail=False) + + return doc diff --git a/drive/patches.txt b/drive/patches.txt index 659609d77..46ba65f56 100644 --- a/drive/patches.txt +++ b/drive/patches.txt @@ -10,3 +10,4 @@ drive.patches.new_writer #3 drive.patches.turn_on_flat_disk drive.patches.add_modified_field drive.patches.add_drive_user_role +drive.patches.integrate_with_framework diff --git a/drive/patches/folder_size.py b/drive/patches/folder_size.py index 713c48a9a..36b587ccb 100644 --- a/drive/patches/folder_size.py +++ b/drive/patches/folder_size.py @@ -3,14 +3,14 @@ def scan(folder): folder = frappe.get_doc("Drive File", folder) - child_folders = frappe.get_list("Drive File", {"parent_entity": folder.name, "is_group": 1}, pluck="name") + child_folders = frappe.get_list("Drive File", {"folder": folder.name, "is_group": 1}, pluck="name") for child in child_folders: scan(child) - sizes = frappe.get_list("Drive File", {"parent_entity": folder.name, "is_active": 1}, pluck="file_size") + sizes = frappe.get_list("Drive File", {"folder": folder.name, "is_active": 1}, pluck="file_size") frappe.db.set_value("Drive File", folder.name, "file_size", sum(sizes), update_modified=False) def execute(): - roots = frappe.get_list("Drive File", {"parent_entity": ""}, pluck="name") + roots = frappe.get_list("Drive File", {"folder": ""}, pluck="name") for root in roots: scan(root) diff --git a/drive/patches/integrate_with_framework.py b/drive/patches/integrate_with_framework.py new file mode 100644 index 000000000..88ca833b0 --- /dev/null +++ b/drive/patches/integrate_with_framework.py @@ -0,0 +1,103 @@ +""" +Create `File` records of all existing `Drive File`s +""" + +import frappe +from drive.utils import MIME_LIST_MAP +from drive.utils.files import get_s3_url + + +def get_file_type(r): + if r["is_group"]: + return "Folder" + if r["is_link"]: + return "Link" + try: + return next(k for (k, v) in MIME_LIST_MAP.items() if r["mime_type"] in v) + except StopIteration: + return "Unknown" + + +def execute(files=[]): + if not files: + root_files = frappe.get_all("Drive File", filters={"parent_entity": ""}, pluck="name") + + is_remote = frappe.get_single("Drive Disk Settings").enabled + for file_id in root_files: + folder = frappe.get_doc("Drive File", file_id) + migrate_folder(folder, is_remote) + + +def migrate_folder(folder, is_remote=False): + print(f"Migrating folder {folder}") + migrate_file(folder) + + for child in folder.get_children(): + if child.is_group or child.doc: + migrate_folder(child, is_remote) + else: + migrate_file(child, is_remote) + + +def get_link(file, is_remote=False): + if file.mime_type == "frappe/slides" or not file.path: + return "" + elif file.is_link: + return file.path + elif is_remote: + return get_s3_url(file.path) + + return "/private/files/" + file.path + + +def migrate_file(file, is_remote=False): + if frappe.db.exists("File", {"is_drive_file": 1, "name": file.name}): + return + + ff_file = frappe.get_doc( + { + "doctype": "File", + "is_drive_file": 1, + "_name": file.name, + "file_name": file.title, + "team": file.team, + "file_url": get_link(file, is_remote), + "folder": file.parent_entity, + "is_folder": file.is_group, + "file_size": file.file_size, + "last_modified": file._modified, + "status": file.is_active, + "mime_type": file.mime_type, + "is_private": 1, + } + ) + + if file.doc: + ff_file.special_file = "Writer Document" + ff_file.special_file_doc = file.doc + + if file.mime_type == "frappe/slides": + ff_file.special_file = "Presentation" + ff_file.special_file_doc = file.path + + # Attachment + if frappe.db.get_value("Drive File", file.parent_entity, "doc"): + ff_file.attached_to_doctype = "File" + ff_file.attached_to_name = file.parent_entity + + # Calculate file type + ff_file.file_type = get_file_type(file.as_dict()) + + settings = {} + if file.color: + settings["color"] = file.color + if not file.allow_download: + settings["forbid_download"] = 1 + ff_file.settings = settings + try: + ff_file.insert() + frappe.db.set_value("File", ff_file.name, "creation", file.creation, update_modified=False) + frappe.db.set_value("File", ff_file.name, "owner", file.owner, update_modified=False) + frappe.db.set_value("File", ff_file.name, "modified", file.modified, update_modified=False) + except Exception as e: + print(f"Error migrating file {file.name}: {e}") diff --git a/drive/patches/remove_personal.py b/drive/patches/remove_personal.py index 8fc77d670..09bd3dfe0 100644 --- a/drive/patches/remove_personal.py +++ b/drive/patches/remove_personal.py @@ -8,7 +8,7 @@ def execute(): print( "This migration to a beta release might CORRUPT your data. Do NOT run this before taking a complete backup. You have two minutes left to cancel this deployment. " ) - time.sleep(120) + time.sleep(120) frappe.reload_doc("Drive", "doctype", "Drive Disk Settings") doc = frappe.get_single("Drive Disk Settings") @@ -52,14 +52,14 @@ def execute(): for f in frappe.get_all( "Drive File", filters={"is_private": 1}, - fields=["name", "is_private", "owner", "parent_entity"], + fields=["name", "is_private", "owner", "folder"], ): try: frappe.db.set_value("Drive File", f.name, "team", MAP[f.owner], update_modified=False) # For root elements, change parent folder - if not frappe.db.get_value("Drive File", f.parent_entity, "parent_entity"): - new_parent = frappe.db.get_value("Drive File", {"team": MAP[f.owner], "parent_entity": None}, "name") - frappe.db.set_value("Drive File", f.name, "parent_entity", new_parent) + if not frappe.db.get_value("Drive File", f.folder, "folder"): + new_parent = frappe.db.get_value("Drive File", {"team": MAP[f.owner], "folder": None}, "name") + frappe.db.set_value("Drive File", f.name, "folder", new_parent) except KeyError: print(f"There was an issue with the file {f} owned by {f.owner}") diff --git a/drive/patches/team_restructure.py b/drive/patches/team_restructure.py index 33543f7dc..902e630b3 100644 --- a/drive/patches/team_restructure.py +++ b/drive/patches/team_restructure.py @@ -75,7 +75,7 @@ def execute(): frappe.db.set_value( "Drive File", name, - "parent_entity", + "folder", home_folder, update_modified=False, ) @@ -83,7 +83,7 @@ def execute(): frappe.db.set_value( "Drive File", name, - "parent_entity", + "folder", translate[k["parent_drive_entity"]], update_modified=False, ) @@ -118,7 +118,7 @@ def execute(): RENAME_MAP = { "Drive Notification": "notif_doctype_name", "Drive Favourite": "entity", - "Drive Document Version": "parent_entity", + "Drive Document Version": "folder", "Drive Entity Activity Log": "entity", "Drive Entity Log": "entity_name", } diff --git a/drive/public/js/FileUploader.vue b/drive/public/js/FileUploader.vue index 6c0fb3076..46386cf57 100644 --- a/drive/public/js/FileUploader.vue +++ b/drive/public/js/FileUploader.vue @@ -66,7 +66,7 @@
@@ -76,141 +76,141 @@