diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ba218c..77b67e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,12 +46,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: "3.14" - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 24 check-latest: true - name: Cache pip @@ -96,7 +96,7 @@ jobs: bench --site test_site install-app forms_pro bench build env: - CI: 'Yes' + CI: "Yes" - name: Run Tests working-directory: /home/runner/frappe-bench diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bb8c19e..277880a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: rev: v2.7.1 hooks: - id: prettier - types_or: [javascript, vue, scss] + types_or: [javascript, vue, scss, ts] # Ignore any files that might contain jinja / bundles exclude: | (?x)^( @@ -45,7 +45,7 @@ repos: rev: v8.44.0 hooks: - id: eslint - types_or: [javascript] + types_or: [javascript, ts] args: ["--quiet"] # Ignore any files that might contain jinja / bundles exclude: | diff --git a/forms_pro/api/form.py b/forms_pro/api/form.py index 6569502..5aed808 100644 --- a/forms_pro/api/form.py +++ b/forms_pro/api/form.py @@ -1,4 +1,20 @@ import frappe +from frappe import _ +from frappe.share import remove +from pydantic import BaseModel, Field + +from forms_pro.api.user import get_user +from forms_pro.forms_pro.doctype.form.form import Form + + +class FormSharedWithResponse(BaseModel): + full_name: str + user_image: str | None + email: str = Field(alias="user") + read: bool + write: bool + share: bool + submit: bool @frappe.whitelist(allow_guest=True) @@ -9,7 +25,7 @@ def get_form_by_route(route: str) -> dict: @frappe.whitelist(allow_guest=True) def get_form(form_id: str) -> dict: - form = frappe.get_doc( + form: Form = frappe.get_doc( "Form", form_id, ) @@ -24,6 +40,66 @@ def get_form(form_id: str) -> dict: } +@frappe.whitelist() +def get_form_shared_with(form_id: str) -> list[frappe.Any]: + """ + Get list of users with which a form is shared. + + We validate the current user has read access to the form. + """ + if not frappe.has_permission( + "Form", + "read", + form_id, + ): + frappe.throw(_("You do not have read access to this form")) + + form: Form = frappe.get_doc("Form", form_id) + shared_with = form.shared_with() + + shared_with_responses = [] + + for user in shared_with: + _user = get_user(user["user"]) + user.update(_user) + shared_with_responses.append(FormSharedWithResponse.model_validate(user).model_dump()) + + return shared_with_responses + + +@frappe.whitelist() +def remove_form_access(form_id: str, user_email: str) -> None: + """ + Remove access to a form for a user. + + We validate the current user has write access to the form. + + args: + form_id: str - The ID of the form to remove access to. + user_email: str - The email of the user to remove access to. + + """ + + if not frappe.has_permission("Form", "write", form_id): + frappe.throw(_("You do not have write access to this form")) + + return remove(doctype="Form", name=form_id, user=user_email, flags={"ignore_permissions": True}) + + +@frappe.whitelist() +def get_doctype_list() -> list[str]: + if not frappe.has_permission("DocType", "read"): + frappe.throw(_("You do not have read access to this doctype")) + + return frappe.db.get_list( + "DocType", + filters={"istable": 0}, + pluck="name", + order_by="name", + limit_page_length=99999, + ) + + @frappe.whitelist() def get_doctype_fields(doctype: str) -> dict: doctype = frappe.get_doc("DocType", doctype) @@ -43,14 +119,3 @@ def get_doctype_fields(doctype: str) -> dict: fields = [field for field in fields if field.fieldtype not in FIELDTYPES_TO_REMOVE] return fields - - -@frappe.whitelist() -def get_doctype_list() -> list[str]: - return frappe.db.get_list( - "DocType", - filters={"istable": 0}, - pluck="name", - order_by="name", - limit_page_length=99999, - ) diff --git a/forms_pro/api/team.py b/forms_pro/api/team.py index 4358c5a..58bfa09 100644 --- a/forms_pro/api/team.py +++ b/forms_pro/api/team.py @@ -1,5 +1,6 @@ import frappe +from forms_pro.forms_pro.doctype.fp_team.fp_team import FPTeam, GetTeamMembersResponse from forms_pro.utils.teams import GetTeamFormsResponseSchema from forms_pro.utils.teams import get_team_forms as get_team_forms_utils @@ -17,3 +18,26 @@ def get_team_forms(team_id: str) -> list[GetTeamFormsResponseSchema]: """ forms = get_team_forms_utils(team_id=team_id) return forms + + +@frappe.whitelist() +def get_team_members(team_id: str) -> list[GetTeamMembersResponse]: + """ + + Get the list of team members in a FP Team. + This endpoint checks if the session user has the permission to read this FP Team DocType + + """ + frappe.has_permission( + doctype="FP Team", + ptype="read", + doc=team_id, + user=frappe.session.user, + throw=True, + ) + print("team_id", team_id) + + team: FPTeam = frappe.get_doc("FP Team", team_id) + members = team.team_members + + return members diff --git a/forms_pro/api/user.py b/forms_pro/api/user.py index 3ffc590..11c49f7 100644 --- a/forms_pro/api/user.py +++ b/forms_pro/api/user.py @@ -30,8 +30,22 @@ def extract_roles(cls, v: list[HasRole]) -> list[str]: return [role.role for role in v] +class GetUserBasicResponse(BaseModel): + full_name: str + user_image: str | None = None + + +@frappe.whitelist() +def get_user(user: str) -> GetUserBasicResponse | None: + """Get basic user data for a given user""" + data = frappe.db.get_value("User", user, ["full_name", "user_image"], as_dict=True) + if not data: + return None + return GetUserBasicResponse.model_validate(data).model_dump() + + @frappe.whitelist() -def get_user() -> GetUserResponseSchema: +def get_current_user() -> GetUserResponseSchema: """ Get Current User Data """ diff --git a/forms_pro/forms_pro/doctype/form/form.py b/forms_pro/forms_pro/doctype/form/form.py index 9378aba..a85deaa 100644 --- a/forms_pro/forms_pro/doctype/form/form.py +++ b/forms_pro/forms_pro/doctype/form/form.py @@ -1,8 +1,11 @@ # Copyright (c) 2025, harsh@buildwithhussain.com and contributors # For license information, please see license.txt +from typing import Any + import frappe from frappe.model.document import Document +from frappe.share import get_users class Form(Document): @@ -34,6 +37,14 @@ class Form(Document): def linked_doctype_doc(self) -> Document: return frappe.get_doc("DocType", self.linked_doctype) + @frappe.whitelist() + def shared_with(self) -> list[dict[str, Any]]: + """ + Get list of users with which this form is shared + """ + users_shared_with = get_users(self.doctype, self.name) + return users_shared_with + def generate_initial_route(self) -> str: return "s/forms_pro_" + frappe.utils.random_string(8) diff --git a/forms_pro/forms_pro/doctype/fp_team/fp_team.py b/forms_pro/forms_pro/doctype/fp_team/fp_team.py index f31c501..e717cb8 100644 --- a/forms_pro/forms_pro/doctype/fp_team/fp_team.py +++ b/forms_pro/forms_pro/doctype/fp_team/fp_team.py @@ -3,6 +3,16 @@ # import frappe from frappe.model.document import Document +from frappe.utils import cached_property +from pydantic import BaseModel, EmailStr + +from forms_pro.api.user import get_user + + +class GetTeamMembersResponse(BaseModel): + full_name: str + user_image: str | None + email: EmailStr class FPTeam(Document): @@ -20,15 +30,25 @@ class FPTeam(Document): users: DF.TableMultiSelect[FPTeamMember] # end: auto-generated types - @property - def team_members(self) -> list[str]: + @cached_property + def team_members(self) -> list[GetTeamMembersResponse]: """ Get the list of team members Returns: list[str] - List of team member email addresses """ - return [member.user for member in self.users] if self.users else [] + if not len(self.users): + return [] + + members = [] + + for member in self.users: + _user = get_user(member.user) + _user["email"] = member.user + members.append(GetTeamMembersResponse.model_validate(_user).model_dump()) + + return members def is_team_member(self, user: str) -> bool: """ diff --git a/forms_pro/modules.txt b/forms_pro/modules.txt index ea7829e..f0d9322 100644 --- a/forms_pro/modules.txt +++ b/forms_pro/modules.txt @@ -1 +1,2 @@ -Forms Pro \ No newline at end of file +Forms Pro +User Forms \ No newline at end of file diff --git a/forms_pro/user_forms/__init__.py b/forms_pro/user_forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/forms_pro/utils/form_generator.py b/forms_pro/utils/form_generator.py index 28faeac..e570a9d 100644 --- a/forms_pro/utils/form_generator.py +++ b/forms_pro/utils/form_generator.py @@ -1,4 +1,6 @@ import frappe +from frappe import _ +from frappe.share import add_docshare FORMS_PRO_ROLE = "Forms Pro User" @@ -7,10 +9,19 @@ def create_form_with_doctype(team_id: str, doctype: str): roles = frappe.get_roles(frappe.session.user) if FORMS_PRO_ROLE not in roles: - frappe.throw("You are not authorized to create a form") - - form_generator = FormGenerator(team_id=team_id, linked_doctype=doctype) - form_generator.generate() + frappe.throw( + _("You are not authorized to create a form"), + frappe.PermissionError, + ) + + try: + form_generator = FormGenerator(team_id=team_id, linked_doctype=doctype) + form_generator.generate() + except Exception as e: + frappe.log_error(f"Error creating form with doctype: {doctype} - {e}") + frappe.throw( + _("Error creating form with doctype: {0} - {1}").format(doctype, e), + ) return { "doctype": form_generator.doctype.name, @@ -22,10 +33,19 @@ def create_form_with_doctype(team_id: str, doctype: str): def create_form(team_id: str): roles = frappe.get_roles(frappe.session.user) if FORMS_PRO_ROLE not in roles: - frappe.throw("You are not authorized to create a form") - - form_generator = FormGenerator(team_id=team_id) - form_generator.generate() + frappe.throw( + _("You are not authorized to create a form"), + frappe.PermissionError, + ) + + try: + form_generator = FormGenerator(team_id=team_id) + form_generator.generate() + except Exception as e: + frappe.log_error(f"Error creating form: {e}") + frappe.throw( + _("Error creating form: {0}").format(e), + ) return { "doctype": form_generator.doctype.name, @@ -47,6 +67,7 @@ def __init__( def generate(self) -> None: self._initialize_doctype() self._initialize_form_document() + frappe.clear_cache() def _initialize_doctype(self) -> None: if self.doctype: @@ -54,39 +75,74 @@ def _initialize_doctype(self) -> None: placeholder_doctype = frappe.new_doc("DocType") placeholder_doctype.name = self._generate_doctype_name() - placeholder_doctype.module = "Forms Pro" + placeholder_doctype.module = "User Forms" placeholder_doctype.custom = True placeholder_doctype.track_changes = True placeholder_doctype.track_views = True placeholder_doctype.index_web_pages_for_search = False - placeholder_doctype.insert(ignore_permissions=True) - - self.doctype = placeholder_doctype - # self.set_placeholder_doctype_fields() + placeholder_doctype.is_submittable = 0 + placeholder_doctype.istable = 0 + placeholder_doctype.editable_grid = 0 + placeholder_doctype.issingle = 0 + placeholder_doctype.is_tree = 0 + placeholder_doctype.is_virtual = 0 + + # Add permissions + placeholder_doctype.append( + "permissions", + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1, + "submit": 0, + }, + ) + + # Add fields + placeholder_doctype.append("fields", {"fieldtype": "Section Break"}) - # def set_placeholder_doctype_fields(self) -> None: - # fields = [ - # { - # "label": "Is Form Pro DocType", - # "fieldname": "is_forms_pro_doctype", - # "fieldtype": "Check", - # "reqd": 1, - # "read_only": 1, - # "default": 1, - # } - # ] + placeholder_doctype.insert(ignore_permissions=True) - # for field in fields: - # self.doctype.append("fields", field) + add_docshare( + "DocType", + placeholder_doctype.name, + read=1, + write=1, + share=1, + submit=0, + flags={ + "ignore_share_permission": True, + }, + ) - # self.doctype.save(ignore_permissions=True) + self.doctype = placeholder_doctype def _initialize_form_document(self) -> None: form_document = frappe.new_doc("Form") form_document.linked_doctype = self.doctype.name form_document.title = "Untitled Form" form_document.linked_team_id = self.team_id + form_document.document_type = "System" form_document.insert(ignore_permissions=True) + + add_docshare( + "Form", + form_document.name, + read=1, + write=1, + share=1, + submit=0, + flags={ + "ignore_share_permission": True, + }, + ) self.form_document = form_document def _generate_doctype_name(self) -> str: diff --git a/forms_pro/utils/test_form_generator.py b/forms_pro/utils/test_form_generator.py index 3ed680a..2998165 100644 --- a/forms_pro/utils/test_form_generator.py +++ b/forms_pro/utils/test_form_generator.py @@ -74,7 +74,7 @@ def test_generate_creates_doctype_when_none_provided(self): # Assertions self.assertIsNotNone(form_generator.doctype) self.assertTrue(form_generator.doctype.name.startswith("formspro_")) - self.assertEqual(form_generator.doctype.module, "Forms Pro") + self.assertEqual(form_generator.doctype.module, "User Forms") self.assertTrue(form_generator.doctype.custom) self.assertTrue(form_generator.doctype.track_changes) self.assertTrue(form_generator.doctype.track_views) @@ -133,7 +133,7 @@ def test_generate_complete_flow(self): # Verify DocType properties self.assertTrue(form_generator.doctype.name.startswith("formspro_")) - self.assertEqual(form_generator.doctype.module, "Forms Pro") + self.assertEqual(form_generator.doctype.module, "User Forms") self.assertTrue(form_generator.doctype.custom) def test_generate_with_existing_doctype_complete_flow(self): @@ -157,3 +157,57 @@ def test_generate_with_existing_doctype_complete_flow(self): # Verify Form document exists in database self.assertTrue(frappe.db.exists("Form", form_generator.form_document.name)) + + def test_generate_creates_doctype_docshare(self): + """Test that generate() creates DocShare for the DocType with correct permissions""" + frappe.set_user(self.test_user) + form_generator = FormGenerator(team_id=self.test_team) + + # Call generate method + form_generator.generate() + + # Query DocShare for the DocType + docshare = frappe.db.get_value( + "DocShare", + { + "share_doctype": "DocType", + "share_name": form_generator.doctype.name, + "user": self.test_user, + }, + ["read", "write", "share", "submit"], + as_dict=True, + ) + + # Assertions + self.assertIsNotNone(docshare, "DocShare should exist for DocType") + self.assertEqual(docshare.read, 1, "DocShare should have read permission") + self.assertEqual(docshare.write, 1, "DocShare should have write permission") + self.assertEqual(docshare.share, 1, "DocShare should have share permission") + self.assertEqual(docshare.submit, 0, "DocShare should not have submit permission") + + def test_generate_creates_form_docshare(self): + """Test that generate() creates DocShare for the Form document with correct permissions""" + frappe.set_user(self.test_user) + form_generator = FormGenerator(team_id=self.test_team) + + # Call generate method + form_generator.generate() + + # Query DocShare for the Form document + docshare = frappe.db.get_value( + "DocShare", + { + "share_doctype": "Form", + "share_name": form_generator.form_document.name, + "user": self.test_user, + }, + ["read", "write", "share", "submit"], + as_dict=True, + ) + + # Assertions + self.assertIsNotNone(docshare, "DocShare should exist for Form document") + self.assertEqual(docshare.read, 1, "DocShare should have read permission") + self.assertEqual(docshare.write, 1, "DocShare should have write permission") + self.assertEqual(docshare.share, 1, "DocShare should have share permission") + self.assertEqual(docshare.submit, 0, "DocShare should not have submit permission") diff --git a/frontend/.gitignore b/frontend/.gitignore index cb723df..bfaab69 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -5,4 +5,5 @@ dist-ssr *.local coverage ../ -sites \ No newline at end of file +sites +src/types/frappe-ui.d.ts \ No newline at end of file diff --git a/frontend/auto-imports.d.ts b/frontend/auto-imports.d.ts index 9d24007..b0b0357 100644 --- a/frontend/auto-imports.d.ts +++ b/frontend/auto-imports.d.ts @@ -5,6 +5,4 @@ // Generated by unplugin-auto-import // biome-ignore lint: disable export {} -declare global { - -} +declare global {} diff --git a/frontend/components.d.ts b/frontend/components.d.ts index e46e47a..9ec01ed 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -3,12 +3,15 @@ // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 // biome-ignore lint: disable -export {} +export {}; /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AccessSection: typeof import('./src/components/form/manage/AccessSection.vue')['default'] AddFieldsSection: typeof import('./src/components/builder/sidebar/AddFieldsSection.vue')['default'] + Avatar: typeof import('./src/components/ui/Avatar.vue')['default'] + DescriptionSection: typeof import('./src/components/form/manage/DescriptionSection.vue')['default'] DoctypeFieldsSection: typeof import('./src/components/builder/sidebar/DoctypeFieldsSection.vue')['default'] FieldEditorSidebar: typeof import('./src/components/FieldEditorSidebar.vue')['default'] FieldPropertiesForm: typeof import('./src/components/builder/FieldPropertiesForm.vue')['default'] @@ -19,11 +22,13 @@ declare module 'vue' { FormHeader: typeof import('./src/components/submission/FormHeader.vue')['default'] FormPreviewCard: typeof import('./src/components/dashboard/FormPreviewCard.vue')['default'] FormRenderer: typeof import('./src/components/submission/FormRenderer.vue')['default'] + RemoveAccessModal: typeof import('./src/components/form/manage/RemoveAccessModal.vue')['default'] RenderField: typeof import('./src/components/RenderField.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] Section: typeof import('./src/components/fields/Section.vue')['default'] SettingsSection: typeof import('./src/components/builder/sidebar/SettingsSection.vue')['default'] + ShareAccessModal: typeof import('./src/components/form/manage/ShareAccessModal.vue')['default'] SuccessSection: typeof import('./src/components/submission/SuccessSection.vue')['default'] } } diff --git a/frontend/index.html b/frontend/index.html index 08c6a25..9f5734d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,7 +10,7 @@
- + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 818485b..5b473e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,8 +13,9 @@ "dependencies": { "@lottiefiles/dotlottie-vue": "^0.10.4", "@vueuse/core": "^13.9.0", + "dayjs": "^1.11.19", "feather-icons": "^4.29.2", - "frappe-ui": "^0.1.214", + "frappe-ui": "^0.1.244", "lucide-vue-next": "^0.543.0", "pinia": "^3.0.3", "socket.io-client": "^4.7.2", diff --git a/frontend/src/components/FormBuilderContent.vue b/frontend/src/components/FormBuilderContent.vue index 6e6559a..d67a833 100644 --- a/frontend/src/components/FormBuilderContent.vue +++ b/frontend/src/components/FormBuilderContent.vue @@ -1,6 +1,6 @@