diff --git a/forms_pro/api/team.py b/forms_pro/api/team.py new file mode 100644 index 0000000..4358c5a --- /dev/null +++ b/forms_pro/api/team.py @@ -0,0 +1,19 @@ +import frappe + +from forms_pro.utils.teams import GetTeamFormsResponseSchema +from forms_pro.utils.teams import get_team_forms as get_team_forms_utils + + +@frappe.whitelist() +def get_team_forms(team_id: str) -> list[GetTeamFormsResponseSchema]: + """ + Get the list of forms for the current team + + Args: + team_id: ID of the team + + Returns: + list[GetTeamFormsResponseSchema] - List of forms for the team + """ + forms = get_team_forms_utils(team_id=team_id) + return forms diff --git a/forms_pro/api/user.py b/forms_pro/api/user.py new file mode 100644 index 0000000..3ffc590 --- /dev/null +++ b/forms_pro/api/user.py @@ -0,0 +1,61 @@ +import frappe +from frappe.core.doctype.has_role.has_role import HasRole +from pydantic import BaseModel, Field, field_validator + +from forms_pro.utils.teams import get_user_teams as get_user_teams_utils + + +class GetUserTeamsResponseSchema(BaseModel): + name: str = Field(description="ID of the team") + team_name: str = Field(description="The name of the team") + is_current: bool = Field(description="Whether this is the current team") + + +class GetUserResponseSchema(BaseModel): + email: str + first_name: str + last_name: str | None = None + full_name: str + username: str + desk_theme: str + roles: list[str] + has_desk_access: bool + + @field_validator("roles", mode="before") + @classmethod + def extract_roles(cls, v: list[HasRole]) -> list[str]: + if not v: + return [] + + return [role.role for role in v] + + +@frappe.whitelist() +def get_user() -> GetUserResponseSchema: + """ + Get Current User Data + """ + + user_id = frappe.session.user + user_doc = frappe.get_doc("User", user_id) + data = user_doc.as_dict() + data["roles"] = user_doc.get("roles") + data["has_desk_access"] = user_doc.has_desk_access() + + return GetUserResponseSchema.model_validate(data).model_dump() + + +@frappe.whitelist() +def get_user_teams() -> list[GetUserTeamsResponseSchema]: + """ + Get the list of teams for the current user + """ + + user = frappe.session.user + + if user == "Guest": + return [] + + teams = get_user_teams_utils(user) + + return [GetUserTeamsResponseSchema.model_validate(team).model_dump() for team in teams] diff --git a/forms_pro/forms_pro/doctype/form/form.json b/forms_pro/forms_pro/doctype/form/form.json index eb770c7..f145089 100644 --- a/forms_pro/forms_pro/doctype/form/form.json +++ b/forms_pro/forms_pro/doctype/form/form.json @@ -11,6 +11,7 @@ "column_break_pqae", "title", "linked_doctype", + "linked_team_id", "settings_section", "login_required", "column_break_xocw", @@ -107,12 +108,19 @@ "fieldname": "allow_incomplete", "fieldtype": "Check", "label": "Allow Incomplete Forms" + }, + { + "fieldname": "linked_team_id", + "fieldtype": "Link", + "label": "Linked Team", + "options": "FP Team", + "reqd": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-10-07 12:16:18.825427", + "modified": "2025-12-20 20:40:24.219032", "modified_by": "Administrator", "module": "Forms Pro", "name": "Form", diff --git a/forms_pro/forms_pro/doctype/form/form.py b/forms_pro/forms_pro/doctype/form/form.py index db4b1ac..9378aba 100644 --- a/forms_pro/forms_pro/doctype/form/form.py +++ b/forms_pro/forms_pro/doctype/form/form.py @@ -21,6 +21,7 @@ class Form(Document): fields: DF.Table[FormField] is_published: DF.Check linked_doctype: DF.Link + linked_team_id: DF.Link login_required: DF.Check metadata: DF.Code | None route: DF.Data | None diff --git a/forms_pro/forms_pro/doctype/form/test_form.py b/forms_pro/forms_pro/doctype/form/test_form.py index 99fe67b..9d1d1ae 100644 --- a/forms_pro/forms_pro/doctype/form/test_form.py +++ b/forms_pro/forms_pro/doctype/form/test_form.py @@ -4,6 +4,8 @@ import frappe from frappe.tests import IntegrationTestCase +from forms_pro.utils.teams import get_user_teams + # 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 @@ -18,9 +20,18 @@ def setUp(self): self.test_doctype_name = "Test Form DocType" self.create_test_doctype() + self.test_user = "test_forms_pro_user@example.com" + self.test_team = get_user_teams(self.test_user)[0]["name"] + # Create a test Form self.test_form = frappe.get_doc( - {"doctype": "Form", "title": "Test Form", "linked_doctype": self.test_doctype_name, "fields": []} + { + "doctype": "Form", + "title": "Test Form", + "linked_doctype": self.test_doctype_name, + "fields": [], + "linked_team_id": self.test_team, + } ) self.test_form.insert() 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 b346ad6..f31c501 100644 --- a/forms_pro/forms_pro/doctype/fp_team/fp_team.py +++ b/forms_pro/forms_pro/doctype/fp_team/fp_team.py @@ -20,4 +20,24 @@ class FPTeam(Document): users: DF.TableMultiSelect[FPTeamMember] # end: auto-generated types - pass + @property + def team_members(self) -> list[str]: + """ + 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 [] + + def is_team_member(self, user: str) -> bool: + """ + Check if a user is a member of the team + + Args: + user: The user email address + + Returns: + bool - True if the user is a member of the team, False otherwise + """ + return user in self.team_members diff --git a/forms_pro/hooks.py b/forms_pro/hooks.py index 8b88771..2274150 100644 --- a/forms_pro/hooks.py +++ b/forms_pro/hooks.py @@ -166,7 +166,7 @@ # Testing # ------- -# before_tests = "forms_pro.install.before_tests" +before_tests = "forms_pro.install.before_tests" # Overriding Methods # ------------------------------ diff --git a/forms_pro/install.py b/forms_pro/install.py new file mode 100644 index 0000000..ef686b1 --- /dev/null +++ b/forms_pro/install.py @@ -0,0 +1,30 @@ +import frappe +from frappe.core.doctype.user.user import User + +from forms_pro.roles import FORMS_PRO_ROLE + + +def before_tests(): + give_admin_forms_pro_role() + create_test_user() + + +def give_admin_forms_pro_role(): + admin = frappe.get_doc("User", "Administrator") + admin.append("roles", {"role": FORMS_PRO_ROLE}) + admin.save() + + +def create_test_user(): + test_user = "test_forms_pro_user@example.com" + + if frappe.db.exists("User", test_user): + return + + user: User = frappe.new_doc("User") + user.email = test_user + user.first_name = "Test" + user.last_name = "Forms Pro User" + user.insert(ignore_permissions=True) + user.append("roles", {"role": FORMS_PRO_ROLE}) + user.save(ignore_permissions=True) diff --git a/forms_pro/overrides/roles.py b/forms_pro/overrides/roles.py index b2b09af..53f8eff 100644 --- a/forms_pro/overrides/roles.py +++ b/forms_pro/overrides/roles.py @@ -2,7 +2,7 @@ from frappe.core.doctype.user.user import User from forms_pro.roles import FORMS_PRO_ROLE -from forms_pro.utils.teams import get_user_teams +from forms_pro.utils.teams import get_user_teams, set_current_team def handle_forms_pro_role_change(doc, method) -> None: @@ -27,7 +27,7 @@ def handle_forms_pro_role_change(doc, method) -> None: create_default_team_for_user(user) -def create_default_team_for_user(user: User): +def create_default_team_for_user(user: User) -> None: from forms_pro.forms_pro.doctype.fp_team.fp_team import FPTeam team: FPTeam = frappe.new_doc("FP Team") @@ -40,3 +40,4 @@ def create_default_team_for_user(user: User): }, ) team.save(ignore_permissions=True) + set_current_team(team.name, user.name) diff --git a/forms_pro/utils/form_generator.py b/forms_pro/utils/form_generator.py index 2365173..28faeac 100644 --- a/forms_pro/utils/form_generator.py +++ b/forms_pro/utils/form_generator.py @@ -4,12 +4,12 @@ @frappe.whitelist() -def create_form_with_doctype(doctype: str): +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(linked_doctype=doctype) + form_generator = FormGenerator(team_id=team_id, linked_doctype=doctype) form_generator.generate() return { @@ -19,12 +19,12 @@ def create_form_with_doctype(doctype: str): @frappe.whitelist() -def create_form(): +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() + form_generator = FormGenerator(team_id=team_id) form_generator.generate() return { @@ -34,8 +34,13 @@ def create_form(): class FormGenerator: - def __init__(self, linked_doctype: str | None = None) -> None: + def __init__( + self, + team_id: str, + linked_doctype: str | None = None, + ) -> None: self.doctype = None + self.team_id = team_id if linked_doctype: self.doctype = frappe.get_doc("DocType", linked_doctype) @@ -80,6 +85,7 @@ 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.insert(ignore_permissions=True) self.form_document = form_document diff --git a/forms_pro/utils/teams.py b/forms_pro/utils/teams.py index 69484a7..3306760 100644 --- a/forms_pro/utils/teams.py +++ b/forms_pro/utils/teams.py @@ -1,4 +1,55 @@ +from datetime import datetime +from typing import Any + import frappe +from frappe.defaults import get_user_default, set_user_default +from frappe.query_builder import Case +from pydantic import BaseModel, Field, field_validator + + +class GetTeamFormsResponseSchema(BaseModel): + """ + Schema for the response of the get_team_forms function + """ + + name: str = Field(description="ID of the form") + title: str = Field(description="Title of the form") + description: str | None = Field(default="", description="Description of the form") + route: str | None = Field(default="", description="Route of the form") + is_published: bool = Field(description="Whether the form is published") + creation: datetime = Field(description="Creation date of the form") + modified: datetime = Field(description="Modification date of the form") + linked_team_id: str = Field(description="Team ID of the form") + + @field_validator("creation", "modified", mode="before") + @classmethod + def parse_datetime(cls, v: Any) -> datetime: + """Convert datetime string to datetime object.""" + if isinstance(v, str): + return frappe.utils.get_datetime(v) + if isinstance(v, datetime): + return v + raise ValueError(f"Invalid datetime value: {v}") + + @field_validator("is_published", mode="before") + @classmethod + def parse_boolean(cls, v: Any) -> bool: + """Convert 0/1 to boolean.""" + if isinstance(v, bool): + return v + if isinstance(v, int): + return bool(v) + if v is None: + return False + raise ValueError(f"Invalid boolean value: {v}") + + @field_validator("description", "route", mode="before") + @classmethod + def handle_none_strings(cls, v: Any) -> str | None: + """Handle None values for optional string fields.""" + if v is None: + return "" + return v def get_user_teams(user: str = frappe.session.user): @@ -9,13 +60,57 @@ def get_user_teams(user: str = frappe.session.user): FP_TEAM = frappe.qb.DocType("FP Team") FP_TEAM_MEMBER = frappe.qb.DocType("FP Team Member") + user_default_team = get_user_default("current_team", user) + query = ( frappe.qb.from_(FP_TEAM) .join(FP_TEAM_MEMBER) .on(FP_TEAM.name == FP_TEAM_MEMBER.parent) .where(FP_TEAM_MEMBER.user == user) - .select(FP_TEAM.team_name, FP_TEAM.name) + .select( + FP_TEAM.team_name, + FP_TEAM.name, + Case().when(FP_TEAM.name == user_default_team, True).else_(False).as_("is_current"), + ) + .orderby(FP_TEAM.creation) ) teams = query.run(as_dict=True) + if user_default_team is None and len(teams) > 0: + teams[0]["is_current"] = True + set_user_default("current_team", teams[0]["name"]) + return teams or [] + + +def set_current_team(team_name: str, user: str = frappe.session.user): + set_user_default("current_team", team_name, user) + + +def get_team_forms(team_id: str) -> list[GetTeamFormsResponseSchema]: + """ + Get the list of forms for a team + + Args: + team_id: ID of the team + + Returns: + list[GetTeamFormsResponseSchema] - List of forms for the team + """ + forms = frappe.get_all( + "Form", + filters={"linked_team_id": team_id}, + fields=[ + "name", + "title", + "description", + "route", + "is_published", + "creation", + "modified", + "linked_team_id", + ], + ) + + data = [GetTeamFormsResponseSchema.model_validate(form).model_dump() for form in forms] + return data diff --git a/forms_pro/utils/test_form_generator.py b/forms_pro/utils/test_form_generator.py index a00b975..3ed680a 100644 --- a/forms_pro/utils/test_form_generator.py +++ b/forms_pro/utils/test_form_generator.py @@ -5,9 +5,19 @@ from frappe.tests import IntegrationTestCase from forms_pro.utils.form_generator import FormGenerator +from forms_pro.utils.teams import get_user_teams class IntegrationTestFormGenerator(IntegrationTestCase): + def setUp(self): + super().setUp() + self.test_user = "test_forms_pro_user@example.com" + self.test_team = get_user_teams(self.test_user)[0]["name"] + + def tearDown(self): + frappe.set_user("Administrator") + super().tearDown() + def test_form_generator_initialization_with_doctype(self): """Test FormGenerator initialization with existing DocType""" # Create a test DocType @@ -18,7 +28,7 @@ def test_form_generator_initialization_with_doctype(self): test_doctype.insert(ignore_permissions=True) # Initialize FormGenerator with existing DocType - form_generator = FormGenerator(linked_doctype=test_doctype.name) + form_generator = FormGenerator(linked_doctype=test_doctype.name, team_id=self.test_team) # Assertions self.assertIsNotNone(form_generator.doctype) @@ -27,14 +37,14 @@ def test_form_generator_initialization_with_doctype(self): def test_form_generator_initialization_without_doctype(self): """Test FormGenerator initialization without DocType""" - form_generator = FormGenerator() + form_generator = FormGenerator(team_id=self.test_team) # Assertions self.assertIsNone(form_generator.doctype) def test_generate_doctype_name_format(self): """Test that generate_doctype_name returns correct format""" - form_generator = FormGenerator() + form_generator = FormGenerator(team_id=self.test_team) doctype_name = form_generator._generate_doctype_name() # Assertions @@ -45,7 +55,7 @@ def test_generate_doctype_name_format(self): def test_generate_doctype_name_uniqueness(self): """Test that generate_doctype_name generates unique names""" - form_generator = FormGenerator() + form_generator = FormGenerator(team_id=self.test_team) names = set() # Generate multiple names and check uniqueness @@ -56,7 +66,7 @@ def test_generate_doctype_name_uniqueness(self): def test_generate_creates_doctype_when_none_provided(self): """Test that generate() creates a new DocType when none is provided""" - form_generator = FormGenerator() + form_generator = FormGenerator(team_id=self.test_team) # Call generate method form_generator.generate() @@ -82,7 +92,7 @@ def test_generate_uses_existing_doctype_when_provided(self): test_doctype.custom = True test_doctype.insert(ignore_permissions=True) - form_generator = FormGenerator(linked_doctype=test_doctype.name) + form_generator = FormGenerator(linked_doctype=test_doctype.name, team_id=self.test_team) # Call generate method form_generator.generate() @@ -93,7 +103,7 @@ def test_generate_uses_existing_doctype_when_provided(self): def test_generate_creates_form_document(self): """Test that generate() creates a Form document""" - form_generator = FormGenerator() + form_generator = FormGenerator(team_id=self.test_team) # Call generate method form_generator.generate() @@ -107,7 +117,7 @@ def test_generate_creates_form_document(self): def test_generate_complete_flow(self): """Test the complete flow of FormGenerator.generate()""" - form_generator = FormGenerator() + form_generator = FormGenerator(team_id=self.test_team) # Call generate method form_generator.generate() @@ -135,7 +145,7 @@ def test_generate_with_existing_doctype_complete_flow(self): test_doctype.custom = True test_doctype.insert(ignore_permissions=True) - form_generator = FormGenerator(linked_doctype=test_doctype.name) + form_generator = FormGenerator(linked_doctype=test_doctype.name, team_id=self.test_team) # Call generate method form_generator.generate() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a3ff02b..210cb0f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,10 @@ +
-

Recents

-

Loading...

-

+

Recent

+

Loading...

+

No forms created yet

-
+
@@ -75,7 +87,8 @@ diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts new file mode 100644 index 0000000..6dbc31e --- /dev/null +++ b/frontend/src/stores/user.ts @@ -0,0 +1,74 @@ +import type { ThemePreferenceType } from "@/utils/theme"; +import setTheme from "@/utils/theme"; +import { createResource } from "frappe-ui"; +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; + +type UserTeam = { + name: string; + team_name: string; + is_current: boolean; +}; + +export const useUser = defineStore("user", () => { + const user = computed(() => userResource.data); + const userTeams = computed(() => userTeamsResource.data); + const currentTeam = ref(null); + + const userResource = createResource({ + url: "forms_pro.api.user.get_user", + }); + + const userTeamsResource = createResource({ + url: "forms_pro.api.user.get_user_teams", + }); + + async function initialize() { + await Promise.all([userResource.fetch(), userTeamsResource.fetch()]); + const _currTeam = getCurrentTeamFromAllTeams(); + if (_currTeam) { + setCurrentTeam(_currTeam); + } + } + + function fetchUser() { + userResource.fetch(); + } + + function fetchUserTeams() { + userTeamsResource.fetch(); + } + + function getCurrentTeamFromAllTeams() { + return userTeams.value?.find((team) => team.is_current); + } + + function setCurrentTeam(team: UserTeam) { + currentTeam.value = team; + } + + async function toggleThemePreference(theme: ThemePreferenceType) { + setTheme(theme); + + const switchTheme = createResource({ + url: "frappe.core.doctype.user.user.switch_theme", + params: { + theme, + }, + }); + await switchTheme.fetch().then(() => { + userResource.reload(); + }); + } + + return { + user, + userTeams, + currentTeam, + initialize, + fetchUser, + fetchUserTeams, + setCurrentTeam, + toggleThemePreference, + }; +}); diff --git a/frontend/src/utils/form_generator.ts b/frontend/src/utils/form_generator.ts index 859c3a7..25694e9 100644 --- a/frontend/src/utils/form_generator.ts +++ b/frontend/src/utils/form_generator.ts @@ -1,11 +1,15 @@ import { createResource } from "frappe-ui"; -export const createNewFormWithDoctype = async (linked_doctype: string) => { +export const createNewFormWithDoctype = async ( + linked_doctype: string, + team_id: string, +) => { const form = createResource({ url: "forms_pro.utils.form_generator.create_form_with_doctype", makeParams() { return { doctype: linked_doctype, + team_id: team_id, }; }, }); @@ -14,9 +18,14 @@ export const createNewFormWithDoctype = async (linked_doctype: string) => { return form.data; }; -export const createNewForm = async () => { +export const createNewForm = async (team_id: string) => { const form = createResource({ url: "forms_pro.utils.form_generator.create_form", + makeParams() { + return { + team_id: team_id, + }; + }, }); await form.fetch(); diff --git a/frontend/src/utils/theme.ts b/frontend/src/utils/theme.ts index bfcd5a2..55e0ce1 100644 --- a/frontend/src/utils/theme.ts +++ b/frontend/src/utils/theme.ts @@ -1,5 +1,8 @@ -export default function toggleTheme() { +export type ThemePreferenceType = "Light" | "Dark" | "Automatic"; + +export default function setTheme(theme: ThemePreferenceType) { + console.log("setTheme", theme); const root = document.documentElement; - const theme = root.getAttribute("data-theme"); - root.setAttribute("data-theme", theme === "light" ? "dark" : "light"); + root.setAttribute("data-theme", theme.toLowerCase()); + console.log("root", root.getAttribute("data-theme")); }