├── .gitignore ├── MANIFEST.in ├── README.md ├── docs ├── DELETE_DOC.md ├── SAVE_DOC.md ├── SET_VALUE.md ├── cursor_pagination.md ├── pagination_examples │ ├── todo_with_user_status_sorting.md │ ├── users_with_number_of_roles.md │ └── users_with_ownership.md ├── resolver_role_permissions.md ├── resolvers_and_exceptions.md └── subscriptions.md ├── frappe_graphql ├── __init__.py ├── api.py ├── cache.py ├── commands │ └── __init__.py ├── config │ ├── __init__.py │ ├── desktop.py │ └── docs.py ├── frappe_graphql │ ├── __init__.py │ ├── doctype │ │ ├── __init__.py │ │ └── graphql_error_log │ │ │ ├── __init__.py │ │ │ ├── graphql_error_log.js │ │ │ ├── graphql_error_log.json │ │ │ ├── graphql_error_log.py │ │ │ ├── graphql_error_log_list.js │ │ │ └── test_graphql_error_log.py │ ├── mutations │ │ ├── delete_doc.py │ │ ├── save_doc.py │ │ ├── set_value.py │ │ ├── subscription_keepalive.py │ │ └── upload_file.py │ ├── queries │ │ └── ping.py │ ├── subscription │ │ └── doc_events.py │ └── types │ │ ├── docfield.graphql │ │ ├── docperm.graphql │ │ ├── doctype.graphql │ │ ├── doctype_action.graphql │ │ ├── doctype_link.graphql │ │ ├── domain.graphql │ │ ├── dynamic_link.graphql │ │ ├── file.graphql │ │ ├── gender.graphql │ │ ├── has_role.graphql │ │ ├── language.graphql │ │ ├── module_def.graphql │ │ ├── role.graphql │ │ ├── role_profile.graphql │ │ ├── root.graphql │ │ ├── subscriptions.graphql │ │ └── user.graphql ├── graphql.py ├── hooks.py ├── modules.txt ├── patches.txt ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py └── utils │ ├── __init__.py │ ├── cursor_pagination.py │ ├── depth_limit_validator.py │ ├── exceptions │ ├── __init__.py │ └── error_coded_exceptions.py │ ├── execution │ └── __init__.py │ ├── file.py │ ├── generate_sdl │ ├── __init__.py │ └── doctype.py │ ├── gql_fields.py │ ├── http.py │ ├── introspection.py │ ├── loader.py │ ├── middlewares │ ├── __init__.py │ └── disable_introspection_queries.py │ ├── permissions.py │ ├── pre_load_schemas.py │ ├── pyutils.py │ ├── resolver │ ├── __init__.py │ ├── child_tables.py │ ├── dataloaders │ │ ├── __init__.py │ │ ├── child_table_loader.py │ │ ├── doctype_loader.py │ │ ├── frappe_dataloader.py │ │ └── locals.py │ ├── link_field.py │ ├── root_query.py │ ├── select_fields.py │ ├── tests │ │ └── test_document_resolver.py │ ├── translate.py │ └── utils.py │ ├── roles.py │ ├── subscriptions.py │ └── tests │ └── test_permissions.py ├── license.txt ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | frappe_graphql/docs/current -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include requirements.txt 3 | include *.json 4 | include *.md 5 | include *.py 6 | include *.txt 7 | recursive-include frappe_graphql *.css 8 | recursive-include frappe_graphql *.csv 9 | recursive-include frappe_graphql *.html 10 | recursive-include frappe_graphql *.ico 11 | recursive-include frappe_graphql *.js 12 | recursive-include frappe_graphql *.json 13 | recursive-include frappe_graphql *.md 14 | recursive-include frappe_graphql *.png 15 | recursive-include frappe_graphql *.py 16 | recursive-include frappe_graphql *.svg 17 | recursive-include frappe_graphql *.txt 18 | recursive-exclude frappe_graphql *.pyc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Frappe Graphql 2 | 3 | GraphQL API Layer for Frappe Framework 4 | 5 | #### License 6 | 7 | MIT 8 | 9 | ## Instructions 10 | Generate the sdls first 11 | ``` 12 | $ bench --site test_site graphql generate_sdl 13 | ``` 14 | and start making your graphql requests against: 15 | ``` 16 | /api/method/graphql 17 | ``` 18 | 19 | # Features 20 | ## Getting single Document and getting a filtered doctype list 21 | You can get a single document by its name using `` query. 22 | For sort by fields, only those fields that are search_indexed / unique can be used. NAME, CREATION & MODIFIED can also be used 23 |
24 | Example 25 | 26 | Query 27 | ``` 28 | { 29 | User(name: "Administrator") { 30 | name, 31 | email 32 | } 33 | } 34 | ``` 35 | You can get a list of documents by querying ``. You can also pass in filters and sorting details as arguments: 36 | ```graphql 37 | { 38 | Users(filter: [["name", "like", "%a%"]], sortBy: { field: NAME, direction: ASC }) { 39 | totalCount, 40 | pageInfo { 41 | hasNextPage, 42 | hasPreviousPage, 43 | startCursor, 44 | endCursor 45 | }, 46 | edges { 47 | cursor, 48 | node { 49 | name, 50 | first_name 51 | } 52 | } 53 | } 54 | } 55 | } 56 | ``` 57 |
58 |
59 | 60 | ## Access Field Linked Documents in nested queries 61 | All Link fields return respective doc. Add `__name` suffix to the link field name to get the link name. 62 |
63 | Example 64 | 65 | Query 66 | ```gql 67 | { 68 | ToDo (limit_page_length: 1) { 69 | name, 70 | priority, 71 | description, 72 | assigned_by__name, 73 | assigned_by { 74 | full_name, 75 | roles { 76 | role__name, 77 | role { 78 | name, 79 | creation 80 | } 81 | } 82 | } 83 | } 84 | } 85 | ``` 86 | Result 87 | ```json 88 | { 89 | "data": { 90 | "ToDo": [ 91 | { 92 | "name": "ae6f39845b", 93 | "priority": "Low", 94 | "description": "

Do this

", 95 | "assigned_by__name": "Administrator", 96 | "assigned_by": { 97 | "full_name": "Administrator", 98 | "roles": [ 99 | { 100 | "role__name": "System Manager", 101 | "role": { 102 | "name": "System Manager", 103 | "creation": "2021-02-02 08:34:42.170306", 104 | } 105 | } 106 | ] 107 | } 108 | } 109 | ... 110 | ] 111 | } 112 | } 113 | ``` 114 |
115 |
116 | 117 | ## Restrict Query/Mutation depth 118 | 119 | Query/Mutation is restricted by default to 10. 120 | 121 | You can change the depth limit by setting the site config `frappe_graphql_depth_limit: 15`. 122 | 123 |
124 | 125 | ## Subscriptions 126 | Get notified instantly of the updates via existing frappe's SocketIO. Please read more on the implementation details [here](./docs/subscriptions.md) 127 |
128 | 129 | ## File Uploads 130 | File uploads can be done following the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). `uploadFile` mutation is included implementing the same 131 | 132 |
133 | Example 134 | 135 | Query 136 | ```http 137 | POST /api/method/graphql HTTP/1.1 138 | Host: test_site:8000 139 | Accept: application/json 140 | Cookie: full_name=Administrator; sid=; system_user=yes; user_id=Administrator; user_image= 141 | Content-Length: 553 142 | Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW 143 | 144 | ----WebKitFormBoundary7MA4YWxkTrZu0gW 145 | Content-Disposition: form-data; name="operations" 146 | 147 | { 148 | "query": "mutation uploadFile($file: Upload!) { uploadFile(file: $file) { name, file_url } }", 149 | "variables": { 150 | "file": null 151 | } 152 | } 153 | ----WebKitFormBoundary7MA4YWxkTrZu0gW 154 | Content-Disposition: form-data; name="map" 155 | 156 | { "0": ["variables.file"] } 157 | ----WebKitFormBoundary7MA4YWxkTrZu0gW 158 | Content-Disposition: form-data; name="0"; filename="/D:/faztp12/Pictures/BingImageOfTheDay_20190715.jpg" 159 | Content-Type: image/jpeg 160 | 161 | (data) 162 | ----WebKitFormBoundary7MA4YWxkTrZu0gW 163 | ``` 164 | 165 | Response 166 | ```json 167 | { 168 | "data": { 169 | "uploadFile": { 170 | "name": "ce36b2e222", 171 | "file_url": "/files/BingImageOfTheDay_20190715.jpg" 172 | } 173 | } 174 | } 175 | ``` 176 |
177 | 178 |
179 | 180 | ## RolePermission integration 181 | Data is returned based on Read access to the resource 182 |
183 | 184 | ## Standard Mutations: set_value , save_doc & delete_doc 185 | - [SET_VALUE Mutation](./docs/SET_VALUE.md) 186 | - [SAVE_DOC Mutation](./docs/SAVE_DOC.md) 187 | - [DELETE_DOC Mutation](./docs/DELETE_DOC.md) 188 |
189 | 190 | ## Pagination 191 | Cursor based pagination is implemented. You can read more about it here: [Cursor Based Pagination](./docs/cursor_pagination.md) 192 |
193 | 194 | ## Support Extensions via Hooks 195 | You can extend the SDLs with additional query / mutations / subscriptions. Examples are provided for a specific set of Scenarios. Please read [GraphQL Spec](http://spec.graphql.org/June2018/#sec-Object-Extensions) regarding Extending types. There are mainly two hooks introduced: 196 | - `graphql_sdl_dir` 197 | Specify a list of directories containing `.graphql` files relative to the app's root directory. 198 | eg: 199 | ```py 200 | # hooks.py 201 | graphql_sdl_dir = [ 202 | "./your-app/your-app/generated/sdl/dir1", 203 | "./your-app/your-app/generated/sdl/dir2", 204 | ] 205 | ``` 206 | The above will look for graphql files in `your-bench/apps/your-app/your-app/generated/sdl/dir1` & `./dir2` folders. 207 | 208 | 209 | - `graphql_schema_processors` 210 | You can pass in a list of cmd that gets executed on schema creation. You are given `GraphQLSchema` object (please refer [graphql-core](https://github.com/graphql-python/graphql-core)) as the only parameter. You can modify it or extend it as per your requirements. 211 | This is a good place to attach the resolvers for the custom SDLs defined via `graphql_sdl_dir` 212 |
213 | 214 | ## Support Extension of Middlewares via hooks 215 | We can add graphql middlewares by adding the path through hooks. 216 | Please note the return type and arguments being passed to your custom middleware. 217 | ```py 218 | # hooks.py 219 | graphql_middlewares = ["frappe_graphql.utils.middlewares.disable_introspection_queries.disable_introspection_queries"] 220 | ``` 221 | ```py 222 | def disable_introspection_queries(next_resolver, obj, info: GraphQLResolveInfo, **kwargs): 223 | # https://github.com/jstacoder/graphene-disable-introspection-middleware 224 | if is_introspection_disabled() and info.field_name.lower() in ['__schema', '__introspection']: 225 | raise IntrospectionDisabled(frappe._("Introspection is disabled")) 226 | 227 | return next_resolver(obj, info, **kwargs) 228 | 229 | 230 | def is_introspection_disabled(): 231 | return not cint(frappe.local.conf.get("developer_mode")) and \ 232 | not cint(frappe.local.conf.get("enable_introspection_in_production")) 233 | ``` 234 |
235 | 236 | ## Introspection in Production 237 | Introspection is disabled by default in production mode. You can enable by setting the site config `enable_introspection_in_production: 1`. 238 | 239 |
240 | 241 | ## Helper wrappers 242 | - Exception Handling in Resolvers. We provide a utility resolver wrapper function which could be used to return your expected exceptions as user errors. You can read more about it [here](./docs/resolvers_and_exceptions.md). 243 | - Role Permissions for Resolver. We provide another utility resolver wrapper function which could be used to verify the logged in User has the roles specified. You can read more about it [here](./docs/resolver_role_permissions.md) 244 |
245 | 246 | ## Examples 247 | ### Adding a newly created DocType 248 | - Generate the SDLs in your app directory 249 | ```bash 250 | # Generate sdls for all doctypes in 251 | $ bench --site test_site graphql generate_sdl --output-dir --app 252 | 253 | # Generate sdls for all doctype in module 254 | $ bench --site test_site graphql generate_sdl --output-dir --module -m -m 255 | 256 | # Generate sdls for doctype <2> 257 | $ bench --site test_site graphql generate_sdl --output-dir --doctype -dt -dt 258 | 259 | # Generate sdls for all doctypes in without Enums for Select Fields 260 | $ bench --site test_site graphql generate_sdl --output-dir --app --disable-enum-select-fields 261 | ``` 262 | - Specify this directory in `graphql_sdl_dir` hook and you are done. 263 | ### Introducing a Custom Field to GraphQL 264 | - Add the `Custom Field` in frappe 265 | - Add the following to a `.graphql` file in your app and specify its directory via `graphql_sdl_dir` 266 | ```graphql 267 | extend type User { 268 | is_super: Int! 269 | } 270 | ``` 271 | 272 | ### Adding a Hello World Query 273 | - Add a cmd in `graphql_schema_processors` hook 274 | - Use the following function definition for the cmd specified: 275 | ```py 276 | def hello_world(schema: GraphQLSchema): 277 | 278 | def hello_resolver(obj, info: GraphQLResolveInfo, **kwargs): 279 | return f"Hello {kwargs.get('name')}!" 280 | 281 | schema.query_type.fields["hello"] = GraphQLField( 282 | GraphQLString, 283 | resolve=hello_resolver, 284 | args={ 285 | "name": GraphQLArgument( 286 | type_=GraphQLString, 287 | default_value="World" 288 | ) 289 | } 290 | ) 291 | ``` 292 | - Now, you can query like query like this: 293 | ```py 294 | # Request 295 | query Hello($var_name: String) { 296 | hello(name: $var_name) 297 | } 298 | 299 | # Variables 300 | { 301 | "var_name": "Mars" 302 | } 303 | 304 | # Response 305 | { 306 | "data": { 307 | "hello": "Hello Mars!" 308 | } 309 | } 310 | ``` 311 | ### Adding a DocMeta Query 312 | 313 | ```py 314 | # Add the cmd to the following function in `graphql_schema_processors` 315 | def docmeta_query(schema): 316 | from graphql import GraphQLField, GraphQLObjectType, GraphQLString, GraphQLInt 317 | schema.query_type.fields["docmeta"] = GraphQLField( 318 | GraphQLList(GraphQLObjectType( 319 | name="DocTypeMeta", 320 | fields={ 321 | "name": GraphQLField( 322 | GraphQLString, 323 | resolve=lambda obj, info: obj 324 | ), 325 | "number_of_docs": GraphQLField( 326 | GraphQLInt, 327 | resolve=lambda obj, info: frappe.db.count(obj) 328 | ), 329 | } 330 | )), 331 | resolve=lambda obj, info: [x.name for x in frappe.get_all("DocType")] 332 | ) 333 | ``` 334 | Please refer `graphql-core` for more examples 335 | 336 | ### Adding a new Mutation 337 | There are two ways: 338 | 1. Write SDL and Attach Resolver to the Schema 339 | ```graphql 340 | # SDL for Mutation 341 | type MY_MUTATION_OUTPUT_TYPE { 342 | success: Boolean 343 | } 344 | 345 | extend type Mutation { 346 | myNewMutation(args): MY_MUTATION_OUTPUT_TYPE 347 | } 348 | ``` 349 | 350 | ```py 351 | # Attach Resolver (Specify the cmd to this function in `graphql_schema_processors` hook) 352 | def myMutationResolver(schema: GraphQLSchema): 353 | def _myMutationResolver(obj: Any, info: GraphQLResolveInfo): 354 | # frappe.set_value(..) 355 | return { 356 | "success": True 357 | } 358 | 359 | mutation_type = schema.mutation_type 360 | mutation_type.fields["myNewMutation"].resolve = _myMutationResolver 361 | ``` 362 | 363 | 2. Make use of `graphql-core` apis 364 | ```py 365 | # Specify the cmd to this function in `graphql_schema_processors` hook 366 | def bindMyNewMutation(schema): 367 | 368 | def _myMutationResolver(obj: Any, info: GraphQLResolveInfo): 369 | # frappe.set_value(..) 370 | return { 371 | "success": True 372 | } 373 | 374 | mutation_type = schema.mutation_type 375 | mutation_type.fields["myNewMutation"] = GraphQLField( 376 | GraphQLObjectType( 377 | name="MY_MUTATION_OUTPUT_TYPE", 378 | fields={ 379 | "success": GraphQLField( 380 | GraphQLBoolean, 381 | resolve=lambda obj, info: obj["success"] 382 | ) 383 | } 384 | ), 385 | resolve=_myMutationResolver 386 | ) 387 | ``` 388 | 389 | ### Modify the Schema randomly 390 | ```py 391 | def schema_processor(schema: GraphQLSchema): 392 | schema.query_type.fields["hello"] = GraphQLField( 393 | GraphQLString, resolve=lambda obj, info: "World!") 394 | ``` 395 | -------------------------------------------------------------------------------- /docs/DELETE_DOC.md: -------------------------------------------------------------------------------- 1 | ## DELETE_DOC Mutation 2 | #### Query 3 | ```graphql 4 | mutation DELETE_DOC($doctype: String!, $name: String!) { 5 | deleteDoc(doctype: $doctype, name: $name) { 6 | doctype, 7 | name, 8 | success 9 | } 10 | } 11 | ``` 12 | #### Variables 13 | ```json 14 | { 15 | "doctype": "Test Doctype", 16 | "name": "Example Doc" 17 | } 18 | ``` 19 | #### Response 20 | ```json 21 | { 22 | "data": { 23 | "deleteDoc": { 24 | "doctype": "Test Doctype", 25 | "name": "Example Doc", 26 | "success": true 27 | } 28 | } 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/SAVE_DOC.md: -------------------------------------------------------------------------------- 1 | ## SAVE_DOC Mutation 2 | 3 | #### Query 4 | ```graphql 5 | mutation SAVE_DOC($doctype: String!, $doc: String!){ 6 | saveDoc(doctype: $doctype, doc: $doc){ 7 | doctype, 8 | name, 9 | doc { 10 | name, 11 | ... on ToDo { 12 | priority 13 | } 14 | } 15 | } 16 | } 17 | ``` 18 | #### Variables 19 | ```json 20 | { 21 | "doctype":"ToDo", 22 | "doc": "{ \"priority\": \"High\", \"description\": \"Test Todo 1\" }" 23 | } 24 | ``` 25 | #### Response 26 | ```json 27 | { 28 | "data": { 29 | "saveDoc": { 30 | "doctype": "ToDo", 31 | "name": "122cec40d0", 32 | "doc": { 33 | "name": "122cec40d0", 34 | "priority": "High" 35 | } 36 | } 37 | } 38 | } 39 | ``` -------------------------------------------------------------------------------- /docs/SET_VALUE.md: -------------------------------------------------------------------------------- 1 | ## SET VALUE Mutation 2 | 3 | #### Query 4 | ```graphql 5 | mutation SET_VALUE($doctype: String!, $name: String!, $fieldname: String!, $value: String!) { 6 | setValue(doctype: $doctype, name: $name, fieldname: $fieldname, value: $value) { 7 | doctype, 8 | name, 9 | fieldname, 10 | value, 11 | doc { 12 | name, 13 | ...on User { 14 | first_name, 15 | last_name, 16 | full_name 17 | } 18 | } 19 | } 20 | } 21 | ``` 22 | #### Variables 23 | ```json 24 | { 25 | "doctype": "User", 26 | "name": "test@test.com", 27 | "fieldname": "first_name", 28 | "value": "Test X" 29 | } 30 | ``` 31 | #### Response 32 | ```json 33 | { 34 | "data": { 35 | "setValue": { 36 | "doctype": "User", 37 | "name": "test@test.com", 38 | "fieldname": "first_name", 39 | "value": "Test", 40 | "doc": { 41 | "name": "test@test.com", 42 | "first_name": "Test X", 43 | "last_name": "Test A", 44 | "full_name": "Test X Test A" 45 | } 46 | } 47 | } 48 | } 49 | ``` -------------------------------------------------------------------------------- /docs/cursor_pagination.md: -------------------------------------------------------------------------------- 1 | ## Cursor Based Pagination 2 | You can pass in `first`, `last`, `after` and `before` 3 | [GraphQL Documentation on Pagination](https://graphql.org/learn/pagination/) 4 | 5 | Example: 6 |
7 | Query 8 |

9 | 10 | ```graphql 11 | { 12 | Users(first: 10) { 13 | totalCount, 14 | pageInfo { 15 | hasNextPage, 16 | hasPreviousPage, 17 | startCursor, 18 | endCursor 19 | }, 20 | edges { 21 | cursor, 22 | node { 23 | name, 24 | first_name 25 | } 26 | } 27 | } 28 | } 29 | ``` 30 |

31 |
32 | 33 |
34 | Response 35 |

36 | 37 | ```json 38 | { 39 | "data": { 40 | "Users": { 41 | "totalCount": 3, 42 | "pageInfo": { 43 | "hasNextPage": false, 44 | "hasPreviousPage": false, 45 | "startCursor": "WwogIjIwMjEtMDItMTMgMjM6MjM6NTUuMjc4MDIzIgpd", 46 | "endCursor": "WwogIjIwMjEtMDItMDIgMDg6MzU6MTIuODM2MzU5Igpd" 47 | }, 48 | "edges": [ 49 | { 50 | "cursor": "WwogIjIwMjEtMDItMTMgMjM6MjM6NTUuMjc4MDIzIgpd", 51 | "node": { 52 | "name": "faztp12@gmail.com", 53 | "first_name": "Test" 54 | } 55 | }, 56 | { 57 | "cursor": "WwogIjIwMjEtMDItMDIgMDg6MzU6MTQuMDI2MDc3Igpd", 58 | "node": { 59 | "name": "Administrator", 60 | "first_name": "Administrator" 61 | } 62 | }, 63 | { 64 | "cursor": "WwogIjIwMjEtMDItMDIgMDg6MzU6MTIuODM2MzU5Igpd", 65 | "node": { 66 | "name": "Guest", 67 | "first_name": "Guest" 68 | } 69 | } 70 | ] 71 | } 72 | } 73 | } 74 | ``` 75 |

76 |
77 | 78 | If you want to implement the same in one of your Custom queries, please check out the following examples here: [Custom & Nested Pagination](./nested_pagination.md) 79 | 80 | More Examples: 81 | - [Get Users with all the documents they `own`](./pagination_examples/users_with_ownership.md) 82 | - [Get Todo list sorted by assigned_user (owner) & status](./pagination_examples/todo_with_user_status_sorting.md) 83 | - Sort Users by number of Roles they got have assigned -------------------------------------------------------------------------------- /docs/pagination_examples/todo_with_user_status_sorting.md: -------------------------------------------------------------------------------- 1 | # Todo sorted by Assigned User, status 2 | 3 | 4 | ## Query & Response 5 |
6 | Query 7 | 8 | ```gql 9 | { 10 | get_todo_with_user_status_sorting(first: 15, after: "", sortBy: { direction: DESC, field: USER_AND_STATUS }) { 11 | totalCount 12 | pageInfo { 13 | hasNextPage 14 | hasPreviousPage 15 | startCursor 16 | endCursor 17 | } 18 | edges { 19 | cursor 20 | node { 21 | doctype 22 | name, 23 | modified 24 | ... on ToDo { 25 | owner__name, 26 | status 27 | } 28 | } 29 | } 30 | } 31 | } 32 | ``` 33 |
34 | 35 |
36 | Response 37 | 38 | ```json 39 | { 40 | "data": { 41 | "get_todo_with_user_status_sorting": { 42 | "totalCount": 11, 43 | "pageInfo": { 44 | "hasNextPage": false, 45 | "hasPreviousPage": false, 46 | "startCursor": "WwogImZhenRwMTJAZ21haWwuY29tIiwKICJPcGVuIiwKICIyMDIxLTA0LTE3IDAyOjIyOjIzLjg1NjQ4NCIKXQ==", 47 | "endCursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIkNsb3NlZCIsCiAiMjAyMS0wNC0xNyAwMjoyMToyOC44NDQ5MTIiCl0=" 48 | }, 49 | "edges": [ 50 | { 51 | "cursor": "WwogImZhenRwMTJAZ21haWwuY29tIiwKICJPcGVuIiwKICIyMDIxLTA0LTE3IDAyOjIyOjIzLjg1NjQ4NCIKXQ==", 52 | "node": { 53 | "doctype": "ToDo", 54 | "name": "6f1cc1afdb", 55 | "modified": "2021-04-17 02:22:23.856484", 56 | "owner__name": "test@gmail.com", 57 | "status": "Open" 58 | } 59 | }, 60 | { 61 | "cursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIk9wZW4iLAogIjIwMjEtMDQtMTYgMDI6MDk6MzUuNjE2OTk0Igpd", 62 | "node": { 63 | "doctype": "ToDo", 64 | "name": "122cec40d0", 65 | "modified": "2021-04-16 02:09:35.616994", 66 | "owner__name": "Administrator", 67 | "status": "Open" 68 | } 69 | }, 70 | { 71 | "cursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIk9wZW4iLAogIjIwMjEtMDQtMDMgMTc6NTU6MTYuOTIyNTA0Igpd", 72 | "node": { 73 | "doctype": "ToDo", 74 | "name": "d165ee497d", 75 | "modified": "2021-04-03 17:55:16.922504", 76 | "owner__name": "Administrator", 77 | "status": "Open" 78 | } 79 | }, 80 | { 81 | "cursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIk9wZW4iLAogIjIwMjEtMDQtMDMgMTc6NTU6MDYuODQ0NTg2Igpd", 82 | "node": { 83 | "doctype": "ToDo", 84 | "name": "c7379202e7", 85 | "modified": "2021-04-03 17:55:06.844586", 86 | "owner__name": "Administrator", 87 | "status": "Open" 88 | } 89 | }, 90 | { 91 | "cursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIk9wZW4iLAogIjIwMjEtMDQtMDMgMTc6NTQ6MzQuOTMxNDA3Igpd", 92 | "node": { 93 | "doctype": "ToDo", 94 | "name": "2c8a93cbe0", 95 | "modified": "2021-04-03 17:54:34.931407", 96 | "owner__name": "Administrator", 97 | "status": "Open" 98 | } 99 | }, 100 | { 101 | "cursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIk9wZW4iLAogIjIwMjEtMDQtMDMgMTc6NTQ6MjUuNjIxNTc4Igpd", 102 | "node": { 103 | "doctype": "ToDo", 104 | "name": "1a13ed221f", 105 | "modified": "2021-04-03 17:54:25.621578", 106 | "owner__name": "Administrator", 107 | "status": "Open" 108 | } 109 | }, 110 | { 111 | "cursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIk9wZW4iLAogIjIwMjEtMDQtMDMgMTc6NTQ6MTIuNjM1NTE4Igpd", 112 | "node": { 113 | "doctype": "ToDo", 114 | "name": "bf208504c4", 115 | "modified": "2021-04-03 17:54:12.635518", 116 | "owner__name": "Administrator", 117 | "status": "Open" 118 | } 119 | }, 120 | { 121 | "cursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIk9wZW4iLAogIjIwMjEtMDQtMDMgMTc6NTI6NTEuNjQ0MDUyIgpd", 122 | "node": { 123 | "doctype": "ToDo", 124 | "name": "402202c9ba", 125 | "modified": "2021-04-03 17:52:51.644052", 126 | "owner__name": "Administrator", 127 | "status": "Open" 128 | } 129 | }, 130 | { 131 | "cursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIk9wZW4iLAogIjIwMjEtMDQtMDMgMTc6NTI6MzkuNDIxNDQ1Igpd", 132 | "node": { 133 | "doctype": "ToDo", 134 | "name": "4c6e172020", 135 | "modified": "2021-04-03 17:52:39.421445", 136 | "owner__name": "Administrator", 137 | "status": "Open" 138 | } 139 | }, 140 | { 141 | "cursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIk9wZW4iLAogIjIwMjEtMDQtMDMgMTc6NTI6MDIuMTUxMjE2Igpd", 142 | "node": { 143 | "doctype": "ToDo", 144 | "name": "307062f0ab", 145 | "modified": "2021-04-03 17:52:02.151216", 146 | "owner__name": "Administrator", 147 | "status": "Open" 148 | } 149 | }, 150 | { 151 | "cursor": "WwogIkFkbWluaXN0cmF0b3IiLAogIkNsb3NlZCIsCiAiMjAyMS0wNC0xNyAwMjoyMToyOC44NDQ5MTIiCl0=", 152 | "node": { 153 | "doctype": "ToDo", 154 | "name": "ae6f39845b", 155 | "modified": "2021-04-17 02:21:28.844912", 156 | "owner__name": "Administrator", 157 | "status": "Closed" 158 | } 159 | } 160 | ] 161 | } 162 | } 163 | } 164 | ``` 165 |
166 | 167 | 168 | ## Code 169 |
170 | Code 171 | 172 | ```py 173 | from graphql import GraphQLSchema, GraphQLField, GraphQLList, GraphQLObjectType, \ 174 | GraphQLInt, GraphQLString, GraphQLInputObjectType, GraphQLEnumType 175 | 176 | import frappe 177 | from frappe_graphql.utils.cursor_pagination import CursorPaginator 178 | 179 | 180 | def bind_todo_with_user_status_sorting(schema: GraphQLSchema): 181 | schema.query_type.fields["get_todo_with_user_status_sorting"] = GraphQLField( 182 | 183 | # Specify args, like first: 10 184 | args=frappe._dict( 185 | first=GraphQLInt, 186 | after=GraphQLString, 187 | last=GraphQLInt, 188 | before=GraphQLString, 189 | 190 | # The sortBy input type 191 | sortBy=GraphQLInputObjectType( 192 | name="TodoWithUserStatusSortingSortInfo", 193 | fields=frappe._dict( 194 | direction=schema.type_map["SortDirection"], 195 | field=GraphQLEnumType( 196 | name="TodoWithUserStatusSortingSortField", 197 | values={ 198 | "USER_AND_STATUS": ["owner", "status", "modified"], 199 | "USER_AND_MODIFIED": ["owner", "modified"] 200 | } 201 | ) 202 | ) 203 | )), 204 | 205 | # resolve to CursorPaginator. Everything else will be taken care of 206 | resolve=lambda obj, info, **kwargs: CursorPaginator(doctype="ToDo").resolve( 207 | obj, info, **kwargs), 208 | 209 | # The return type 210 | type_=GraphQLObjectType( 211 | "TodoWithUserStatusSorting", 212 | frappe._dict( 213 | totalCount=GraphQLInt, 214 | pageInfo=schema.type_map["PageInfo"], 215 | edges=GraphQLList( 216 | GraphQLObjectType( 217 | "TodoWithUserStatusSorting", 218 | frappe._dict( 219 | cursor=GraphQLString, 220 | node=schema.type_map["BaseDocType"]) 221 | )) 222 | ))) 223 | 224 | ``` 225 |
226 | -------------------------------------------------------------------------------- /docs/pagination_examples/users_with_number_of_roles.md: -------------------------------------------------------------------------------- 1 | # Users with NumRoles 2 | In this example, sorting is done on an aggregated field, and conditions are applied in `HAVING` clause 3 | 4 |
5 | Query 6 | 7 | ```gql 8 | { 9 | user_with_number_of_roles(first: 15, sortBy: { direction: DESC, field: NUM_ROLES }) { 10 | totalCount 11 | pageInfo { 12 | hasNextPage 13 | hasPreviousPage 14 | startCursor 15 | endCursor 16 | } 17 | edges { 18 | cursor 19 | node { 20 | full_name 21 | num_roles 22 | user { 23 | email, 24 | modified 25 | } 26 | } 27 | } 28 | } 29 | } 30 | ``` 31 |
32 | 33 |
34 | Response 35 | 36 | ```json 37 | { 38 | "data": { 39 | "user_with_number_of_roles": { 40 | "totalCount": 8, 41 | "pageInfo": { 42 | "hasNextPage": false, 43 | "hasPreviousPage": false, 44 | "startCursor": "WwogMjUsCiAiQWRtaW5pc3RyYXRvciIKXQ==", 45 | "endCursor": "WwogMSwKICJHdWVzdCIKXQ==" 46 | }, 47 | "edges": [ 48 | { 49 | "cursor": "WwogMjUsCiAiQWRtaW5pc3RyYXRvciIKXQ==", 50 | "node": { 51 | "full_name": "Administrator", 52 | "num_roles": 25, 53 | "user": { 54 | "email": "admin@example.com", 55 | "modified": "2021-02-02 08:35:14.026077" 56 | } 57 | } 58 | }, 59 | { 60 | "cursor": "WwogMjIsCiAiVGVzdCBBbGkgWmFpbiIKXQ==", 61 | "node": { 62 | "full_name": "Test User", 63 | "num_roles": 22, 64 | "user": { 65 | "email": "test_user@gmail.com", 66 | "modified": "2021-02-13 23:23:55.278023" 67 | } 68 | } 69 | }, 70 | { 71 | "cursor": "WwogMjAsCiAiVCAzIgpd", 72 | "node": { 73 | "full_name": "T 3", 74 | "num_roles": 20, 75 | "user": { 76 | "email": "t3@t.com", 77 | "modified": "2021-04-17 03:05:16.303065" 78 | } 79 | } 80 | }, 81 | { 82 | "cursor": "WwogNiwKICJUIDIiCl0=", 83 | "node": { 84 | "full_name": "T 2", 85 | "num_roles": 6, 86 | "user": { 87 | "email": "t2@t.com", 88 | "modified": "2021-04-17 03:32:21.515262" 89 | } 90 | } 91 | }, 92 | { 93 | "cursor": "WwogNSwKICJUIDUiCl0=", 94 | "node": { 95 | "full_name": "T 5", 96 | "num_roles": 5, 97 | "user": { 98 | "email": "t5@t.com", 99 | "modified": "2021-04-17 03:33:20.008605" 100 | } 101 | } 102 | }, 103 | { 104 | "cursor": "WwogMSwKICJUIDQiCl0=", 105 | "node": { 106 | "full_name": "T 4", 107 | "num_roles": 1, 108 | "user": { 109 | "email": "t4@t.com", 110 | "modified": "2021-04-05 08:32:48.494305" 111 | } 112 | } 113 | }, 114 | { 115 | "cursor": "WwogMSwKICJUIDEiCl0=", 116 | "node": { 117 | "full_name": "T 1", 118 | "num_roles": 1, 119 | "user": { 120 | "email": "t1@t.com", 121 | "modified": "2021-04-05 08:32:03.416163" 122 | } 123 | } 124 | }, 125 | { 126 | "cursor": "WwogMSwKICJHdWVzdCIKXQ==", 127 | "node": { 128 | "full_name": "Guest", 129 | "num_roles": 1, 130 | "user": { 131 | "email": "guest@example.com", 132 | "modified": "2021-04-06 19:59:00.394288" 133 | } 134 | } 135 | } 136 | ] 137 | } 138 | } 139 | } 140 | ``` 141 |
142 | 143 |
144 | Code 145 | 146 | ```py 147 | import graphql as gql 148 | 149 | import frappe 150 | from frappe_graphql.utils.cursor_pagination import CursorPaginator 151 | from graphql.type.definition import GraphQLObjectType 152 | from graphql.type.scalars import GraphQLInt, GraphQLString 153 | 154 | 155 | def bind_user_with_number_of_roles(schema: gql.GraphQLSchema): 156 | 157 | def node_resolver( 158 | paginator: CursorPaginator, filters=None, sorting_fields=[], sort_dir="asc", limit=5): 159 | 160 | # When after OR before is specified, we expect a SINGLE filter 161 | conditions = " HAVING {}".format(filters[0]) if len(filters) else "" 162 | if conditions: 163 | conditions = conditions.replace("`tabUser`.num_roles", "num_roles") 164 | conditions = conditions.replace("`tabUser`.full_name", "full_name") 165 | 166 | data = frappe.db.sql(f""" 167 | SELECT 168 | user.name, 169 | 'User' as doctype, 170 | user.full_name, 171 | COUNT(*) AS num_roles 172 | FROM `tabUser` user 173 | JOIN `tabHas Role` has_role 174 | ON has_role.parent = user.name AND has_role.parenttype = "User" 175 | GROUP BY has_role.parent 176 | {conditions} 177 | ORDER BY {", ".join([ 178 | f"{'user.' if x == 'modified' else ''}{x} {sort_dir}" 179 | for x in sorting_fields 180 | ])} 181 | LIMIT {limit} 182 | """, as_dict=1) 183 | 184 | for r in data: 185 | r.user = frappe._dict(doctype="User", name=r.name) 186 | 187 | return data 188 | 189 | def count_resolver(paginator: CursorPaginator, filters=None): 190 | return frappe.db.count("User") 191 | 192 | schema.query_type.fields["user_with_number_of_roles"] = gql.GraphQLField( 193 | 194 | args=frappe._dict( 195 | first=gql.GraphQLInt, 196 | after=gql.GraphQLString, 197 | last=gql.GraphQLInt, 198 | before=gql.GraphQLString, 199 | sortBy=gql.GraphQLArgument( 200 | default_value={ 201 | "direction": "DESC", 202 | "field": "NUM_ROLES" 203 | }, 204 | type_=gql.GraphQLInputObjectType( 205 | name="UserWithNumRolesSortInfo", 206 | fields=frappe._dict( 207 | direction=schema.type_map["SortDirection"], 208 | field=gql.GraphQLEnumType( 209 | name="UserWithNumRolesSortField", 210 | values={ 211 | "NUM_ROLES": ["num_roles", "full_name"], 212 | "NAME": ["full_name", "modified"] 213 | } 214 | ) 215 | ) 216 | ) 217 | ) 218 | ), 219 | 220 | # return type 221 | type_=gql.GraphQLObjectType( 222 | name="UserWithNumRolesConnection", 223 | fields=frappe._dict( 224 | totalCount=GraphQLInt, 225 | pageInfo=schema.type_map["PageInfo"], 226 | edges=gql.GraphQLList(gql.GraphQLObjectType( 227 | name="UserWithNumRolesNode", 228 | fields=frappe._dict( 229 | cursor=GraphQLString, 230 | node=GraphQLObjectType( 231 | name="UserWithNumRolesNodeDetails", 232 | fields=frappe._dict( 233 | num_roles=GraphQLInt, 234 | full_name=GraphQLString, 235 | user=schema.type_map["User"] 236 | ) 237 | ) 238 | ) 239 | )) 240 | ) 241 | ), 242 | 243 | resolve=lambda obj, info, **kwargs: CursorPaginator( 244 | doctype="User", 245 | node_resolver=node_resolver, 246 | count_resolver=count_resolver 247 | ).resolve(obj, info, **kwargs) 248 | ) 249 | 250 | ``` 251 |
-------------------------------------------------------------------------------- /docs/resolver_role_permissions.md: -------------------------------------------------------------------------------- 1 | # Resolver Role Permissions 2 | 3 | You can make use of `REQUIRE_ROLES` wrapper function to validate the Roles' of logged in User. 4 | ```py 5 | from frappe_graphql import REQUIRE_ROLES 6 | 7 | @REQUIRE_ROLES(role="Guest") 8 | def ping_resolver(obj, info, **kwargs): 9 | return "pong 10 | ``` 11 | 12 | - You can pass in multiple roles: 13 | ```py 14 | from frappe_graphql import REQUIRE_ROLES 15 | 16 | @REQUIRE_ROLES(role=["Accounts User", "Accounts Manager"]) 17 | def ping_resolver(obj, info, **kwargs): 18 | return "pong 19 | ``` 20 | - You can control the exception raised. `frappe.PermissionError` is raised by default 21 | ```py 22 | from frappe_graphql import REQUIRE_ROLES 23 | 24 | @REQUIRE_ROLES(role="Accounts User", exc=MyCustomException) 25 | def ping_resolver(obj, info, **kwargs): 26 | return "pong 27 | ``` -------------------------------------------------------------------------------- /docs/resolvers_and_exceptions.md: -------------------------------------------------------------------------------- 1 | # Resolvers & Exceptions 2 | You can wrap your resolver functions with our utility function, `ERROR_CODED_EXCEPTIONS` to automatically handle expected exceptions. 3 | 4 | Example Query 5 | 6 |
Query 7 | 8 | ```gql 9 | { 10 | MAGIC_DOOR(pwd: "Open Sesame!") { 11 | errors { 12 | error_code 13 | message 14 | } 15 | message 16 | } 17 | } 18 | ``` 19 |
20 | 21 |
Response 22 | 23 | ```json 24 | { 25 | "MAGIC_DOOR": { 26 | "errors": [ 27 | { 28 | "error_code": "DONT_YOU_KNOW_PWD_CHANGED", 29 | "message": "Password was changed" 30 | } 31 | ], 32 | "message": null 33 | } 34 | } 35 | ``` 36 |
37 | 38 |
Python 39 | 40 | ```py 41 | import frappe 42 | from enum import Enum 43 | from graphql import GraphQLSchema, GraphQLField, GraphQLList, \ 44 | GraphQLObjectType, GraphQLResolveInfo, GraphQLNonNull, GraphQLString, GraphQLEnumType 45 | 46 | from frappe_graphql import ERROR_CODED_EXCEPTIONS, GQLExecutionUserError 47 | 48 | 49 | class MAGIC_DOOR_ERROR_CODES(Enum): 50 | NOT_EVEN_CLOSE = "NOT_EVEN_CLOSE" 51 | DONT_YOU_KNOW_PWD_CHANGED = "DONT_YOU_KNOW_PWD_CHANGED" 52 | 53 | 54 | def bind(schema: GraphQLSchema): 55 | schema.type_map["MAGIC_DOOR_ERROR_CODES"] = GraphQLEnumType( 56 | "MAGIC_DOOR_ERROR_CODES", MAGIC_DOOR_ERROR_CODES) 57 | 58 | schema.type_map["MAGIC_DOOR_ERROR_TYPE"] = GraphQLObjectType("MAGIC_DOOR_ERROR_TYPE", { 59 | "error_code": schema.type_map['MAGIC_DOOR_ERROR_CODES'], 60 | "message": GraphQLString 61 | }) 62 | 63 | schema.type_map["MAGIC_DOOR_TYPE"] = GraphQLObjectType( 64 | "MAGIC_DOOR_TYPE", 65 | fields={ 66 | "errors": GraphQLField(GraphQLList(schema.type_map['MAGIC_DOOR_ERROR_TYPE'])), 67 | "message": GraphQLString, 68 | }, 69 | ) 70 | 71 | schema.query_type.fields["MAGIC_DOOR"] = GraphQLField( 72 | type_=schema.type_map["MAGIC_DOOR_TYPE"], 73 | args={ 74 | "pwd": GraphQLNonNull(GraphQLString) 75 | }, 76 | resolve=magic_door_resolver) 77 | 78 | 79 | class InvalidPassword(GQLExecutionUserError): 80 | error_code = MAGIC_DOOR_ERROR_CODES.NOT_EVEN_CLOSE.value 81 | 82 | 83 | class PasswordChanged(GQLExecutionUserError): 84 | error_code = MAGIC_DOOR_ERROR_CODES.DONT_YOU_KNOW_PWD_CHANGED.value 85 | 86 | 87 | @ERROR_CODED_EXCEPTIONS() 88 | def magic_door_resolver(obj, info: GraphQLResolveInfo, **kwargs): 89 | pwd = kwargs.get("pwd") 90 | if pwd == "Open Sesame!": 91 | raise PasswordChanged() 92 | elif pwd == "Open Noodle!": 93 | return frappe._dict( 94 | message="You get one coin!" 95 | ) 96 | else: 97 | raise InvalidPassword() 98 | 99 | ``` 100 |
-------------------------------------------------------------------------------- /docs/subscriptions.md: -------------------------------------------------------------------------------- 1 | # Subscriptions 2 | GraphQL Spec allows us to be really flexible in choosing the transport for realtime communication. Here, frappe's existing Socket IO implementation is being used to get the same. 3 | 4 | Our target is to have an implementation that conforms to: 5 | https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md 6 | 7 | Server --> Client Message Types: 8 | - GQL_DATA 9 | - GQL_COMPLETE 10 | 11 | Only the above two are implemented as of now. Once we have a mechanism for SocketIO -> Python communication in frappe, we can implement the complete spec 12 | which includes types like: 13 | - GQL_START 14 | - GQL_STOP 15 | - GQL_CONNECTION_ACK 16 | - GQL_CONNECTION_KEEP_ALIVE 17 | 18 | ## Protocol Overview 19 | 1. Client will send in a GQL Subscription Query 20 |
Example 21 | 22 | ```gql 23 | subscription { 24 | doc_events(doctypes: ["User"]) { 25 | subscription_id 26 | doctype 27 | name 28 | document { 29 | ... on User { 30 | email 31 | full_name 32 | } 33 | } 34 | } 35 | } 36 | ``` 37 |
38 | 2. Server will store the variables and field selections, and gives back `subscription_id` to the client 39 |
Server Response Example 40 | 41 | ```json 42 | { 43 | "data": { 44 | "doc_events": { 45 | "subscription_id": "9cbj89kcv" 46 | } 47 | } 48 | } 49 | ``` 50 |
51 | 3. Client will have to emit `task_subscribe` with data `[subscription_id]` via SocketIO connection and listen for subscription events coming under the event name same as the subscription name 52 |
Example 53 | 54 | ```js 55 | frappe.socketio.socket.emit("task_subscribe", [subscription_id]); 56 | frappe.socketio.socket.on("doc_events", (data) => { 57 | console.log("doc_events received: ", data); 58 | }) 59 | ``` 60 |
61 | 4. Periodically, send in keep alive requests. You can do this in two ways, via frappe-cmd, or gql-mutation. This is mandatory, otherwise your subscription will be lost after 5minutes. So please choose an interval accordingly, perhaps every minute ? 62 |
Frappe CMD Example 63 | 64 | ```js 65 | frappe.call({ 66 | cmd: "frappe_graphql.utils.subscriptions.subscription_keepalive", 67 | args: { 68 | subscription: "doc_events", 69 | subscription_id: "9cbj89kcv" 70 | }, 71 | }) 72 | ``` 73 |
74 |
GQL Mutation Example 75 | 76 | ```gql 77 | mutation { 78 | subscriptionKeepAlive(subscription: "doc_events", subscription_id: "483f4bdb") { 79 | error 80 | success 81 | subscribed_at 82 | subscription_id 83 | variables 84 | } 85 | } 86 | ``` 87 |
88 | 5. Done, wait for your subscription events. 89 | 6. By default, Subscriptions auto-complete on Error. You can change the behavior while calling `setup_subscription(complete_on_error=False)` 90 | 7. You can complete manually by calling `complete_subscription("doc_events", "a789df0")` 91 | 92 | ## Creating New Subscriptions 93 | frappe_graphql provides a couple of subscription utility functions. They can be called to make events easily. Some of them are: 94 | - `frappe_graphql.setup_subscription` 95 | - `frappe_graphql.get_consumers` 96 | - `frappe_graphql.notify_consumer` 97 | - `frappe_graphql.notify_consumers` 98 | - `frappe_graphql.notify_all_consumers` 99 | - `frappe_graphql.complete_subscription` 100 | 101 | Please go through the following examples to get better idea on when to use them 102 | 103 |
104 | 105 | ### Example: Doc Events 106 | Let's make a subscription, `doc_events` which will receive doctype on_change events. 107 | 108 |
Implementation 109 | 110 | #### 1. Define Subscription in SDL 111 | 112 | `BaseSubscription` is an interface with single field, `subscription_id` 113 | 114 | ```gql 115 | type DocEvent implements BaseSubscription { 116 | doctype: String! 117 | name: String! 118 | event: String! 119 | document: BaseDocType! 120 | triggered_by: User! 121 | subscription_id: String! 122 | } 123 | 124 | extend type Subscription { 125 | doc_events(doctypes: [String!]): DocEvent! 126 | } 127 | ``` 128 | 129 | #### 2. Bind Resolvers 130 | 131 | In your `graphql_schema_processors` add the py module path to the following function: 132 | ```py 133 | from frappe_graphql import setup_subscription 134 | 135 | def doc_events_bind(schema: GraphQLSchema): 136 | schema.subscription_type.fields["doc_events"].resolve = doc_events_resolver 137 | 138 | def doc_events_resolver(obj, info: GraphQLResolveInfo, **kwargs): 139 | return setup_subscription( 140 | subscription="doc_events", 141 | info=info, 142 | variables=kwargs 143 | ) 144 | ``` 145 | 146 | #### 3. Define Event Source 147 | 148 | Event source can be anything. Frappe Doc Events, or any other hooks. 149 | For the purpose of our example, we will use `doc_events` hook 150 | 151 | in `/hooks.py` define a `doc_events['*']['on_change']` for the following function: 152 | ```py 153 | def on_change(doc, method=None): 154 | frappe.enqueue( 155 | notify_consumers, 156 | enqueue_after_commit=True, 157 | doctype=doc.doctype, 158 | name=doc.name, 159 | triggered_by=frappe.session.user 160 | ) 161 | 162 | 163 | def notify_consumers(doctype, name, triggered_by): 164 | # Verify DocType type has beed defined in SDL 165 | schema = get_schema() 166 | if not schema.get_type(get_singular_doctype(doctype)): 167 | return 168 | 169 | for consumer in get_consumers("doc_events"): 170 | variables = frappe._dict(frappe.parse_json(consumer.variables or "{}")) 171 | if variables.get("doctypes") and doctype not in variables["doctypes"]: 172 | continue 173 | 174 | doctypes = consumer.variables.get("doctypes", []) 175 | if len(doctypes) and doctype not in doctypes: 176 | continue 177 | 178 | notify_consumer( 179 | subscription="doc_events", 180 | subscription_id=consumer.subscription_id, 181 | data=frappe._dict( 182 | event="on_change", 183 | doctype=doctype, 184 | name=name, 185 | document=frappe._dict( 186 | doctype=doctype, 187 | name=name 188 | ), 189 | triggered_by=frappe._dict( 190 | doctype="User", 191 | name=triggered_by 192 | ) 193 | )) 194 | 195 | ``` 196 | 197 |
198 | 199 |
200 | 201 | ### Example: User Login 202 | 203 | Another example subscription that gets triggered whenever a User logs in. 204 | 205 |
Implementation 206 | 207 | #### 1. Define Subscription in SDL 208 | ```gql 209 | type UserLogin implements BaseSubscription { 210 | user: User 211 | subscription_id: String! 212 | } 213 | 214 | 215 | extend type Subscription { 216 | user_login: UserLogin! 217 | } 218 | ``` 219 | 220 | #### 2. Bind Resolvers 221 | 222 | In your `graphql_schema_processors` define the following function 223 | 224 | ```py 225 | import frappe 226 | from graphql import GraphQLSchema, GraphQLResolveInfo 227 | from frappe_graphql import setup_subscription 228 | 229 | def bind(schema: GraphQLSchema): 230 | schema.subscription_type.fields["user_login"].resolve = user_login_resolver 231 | 232 | 233 | def user_login_resolver(obj, info: GraphQLResolveInfo, **kwargs): 234 | frappe.only_for("System Manager") 235 | return setup_subscription( 236 | subscription="user_login", 237 | info=info, 238 | variables=kwargs 239 | ) 240 | ``` 241 | 242 | #### 3. Define Event Source 243 | in your `/hooks.py` define `on_login` with the py module path of the following function: 244 | 245 | ```py 246 | from frappe_graphql import notify_all_consumers 247 | 248 | def on_login(login_manager): 249 | frappe.enqueue( 250 | notify_all_consumers, 251 | enqueue_after_commit=True, 252 | subscription="user_login", 253 | data=frappe._dict( 254 | user=frappe._dict(doctype="User", name=login_manager.user) 255 | )) 256 | ``` 257 | 258 |
259 | 260 |
261 | 262 | ### Example Client Code 263 |
Javascript Client Code 264 | 265 | Please install: 266 | - socket.io-client (2.x) 267 | - axios 268 | - tough-cookie 269 | ```js 270 | const axios = require("axios").default; 271 | const io = require("socket.io-client"); 272 | const toughCookie = require("tough-cookie"); 273 | 274 | 275 | const authCookies = []; 276 | const TEST_SITE = "http://test_site:8000" 277 | const SOCKETIO_IO_URL = "http://test_site:9000" 278 | const USER = "administrator"; 279 | const PWD = "admin" 280 | 281 | async function main() { 282 | await authenticate() 283 | await validateAuth() 284 | 285 | const socketio_client = await getSocketIOClient(); 286 | await subscribeToDocEvents(socketio_client) 287 | } 288 | 289 | async function authenticate() { 290 | await axios.post(`${TEST_SITE}/api/method/login`, null, { 291 | params: { 292 | usr: USER, 293 | pwd: PWD 294 | } 295 | }).then(r => { 296 | if (r.status !== 200) { 297 | throw new Exception() 298 | } 299 | authCookies.push(...r.headers["set-cookie"].map(toughCookie.Cookie.parse)) 300 | }).catch(r => 301 | console.error("Auth Error", r) 302 | ) 303 | } 304 | 305 | async function validateAuth() { 306 | await axios.post(`${TEST_SITE}/api/method/frappe.auth.get_logged_user`, null, { 307 | headers: { 308 | ...getAuthCookieHeader() 309 | } 310 | }) 311 | .then(r => console.log("Auth Verified:", r.data.user)) 312 | .catch(r => console.error("Auth Verification Error", r)) 313 | } 314 | 315 | async function getSocketIOClient() { 316 | const socket = io(SOCKETIO_IO_URL, { 317 | extraHeaders: { 318 | "Origin": TEST_SITE, 319 | ...getAuthCookieHeader() 320 | } 321 | }); 322 | socket.on("message", d => console.log("SocketIO Message:", d)); 323 | 324 | // Make sure you get these messages. 325 | // socket.on("list_update", d => console.log("list_update", d)); 326 | 327 | while (!socket.connected) { 328 | console.log("Connecting to SocketIO..") 329 | await asyncSleep(2000); 330 | } 331 | await asyncSleep(2000); 332 | return socket; 333 | } 334 | 335 | async function subscribeToDocEvents(socketio_client) { 336 | const query = ` 337 | subscription { 338 | doc_events(doctypes: ["User", "ToDo"]) { 339 | subscription_id 340 | doctype 341 | name 342 | document { 343 | ... on User { 344 | email 345 | full_name 346 | } 347 | } 348 | } 349 | } 350 | ` 351 | const sub_id = await axios.post(`${TEST_SITE}/api/method/graphql`, { query }, { 352 | headers: { 353 | ...getAuthCookieHeader() 354 | } 355 | }).then(r => { 356 | return r.data.data.doc_events.subscription_id; 357 | }) 358 | console.log("DocEvents SubID:", sub_id) 359 | 360 | socketio_client.emit("task_subscribe", [sub_id]) 361 | socketio_client.on("doc_events", d => console.log("doc_events", d)) 362 | } 363 | 364 | function getAuthCookieHeader() { 365 | return { 366 | cookie: authCookies.map(x => x.cookieString()).join("; ") 367 | } 368 | } 369 | 370 | function asyncSleep(millis) { 371 | return new Promise(res => { 372 | setTimeout(res, millis); 373 | }) 374 | } 375 | 376 | main() 377 | ``` 378 |
-------------------------------------------------------------------------------- /frappe_graphql/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import frappe 5 | from frappe.utils import cint 6 | from .utils.cursor_pagination import CursorPaginator # noqa 7 | from .utils.loader import get_schema # noqa 8 | from .utils.exceptions import ERROR_CODED_EXCEPTIONS, GQLExecutionUserError, \ 9 | GQLExecutionUserErrorMultiple # noqa 10 | from .utils.roles import REQUIRE_ROLES # noqa 11 | from .utils.subscriptions import setup_subscription, get_consumers, notify_consumer, \ 12 | notify_consumers, notify_all_consumers, subscription_keepalive, complete_subscription # noqa 13 | 14 | __version__ = '1.0.0' 15 | 16 | if not cint(frappe.get_conf().get("developer_mode")): 17 | from graphql.pyutils import did_you_mean 18 | did_you_mean.__globals__['MAX_LENGTH'] = 0 19 | -------------------------------------------------------------------------------- /frappe_graphql/api.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLError, validate, parse 2 | from typing import List 3 | 4 | import frappe 5 | from frappe.utils import cint, strip_html_tags 6 | from . import get_schema 7 | from .graphql import execute 8 | from .utils.depth_limit_validator import depth_limit_validator 9 | 10 | from .utils.http import get_masked_variables, get_operation_name 11 | 12 | 13 | @frappe.whitelist(allow_guest=True) 14 | def execute_gql_query(): 15 | query, variables, operation_name = get_query() 16 | validation_errors = validate( 17 | schema=get_schema(), 18 | document_ast=parse(query), 19 | rules=( 20 | depth_limit_validator( 21 | max_depth=cint(frappe.local.conf.get("frappe_graphql_depth_limit")) or 10 22 | ), 23 | ) 24 | ) 25 | if validation_errors: 26 | output = frappe._dict(errors=validation_errors) 27 | else: 28 | output = execute( 29 | query=query, 30 | variables=variables, 31 | operation_name=operation_name 32 | ) 33 | 34 | frappe.clear_messages() 35 | frappe.local.response = output 36 | if len(output.get("errors", [])): 37 | frappe.db.rollback() 38 | log_error(query, variables, operation_name, output) 39 | frappe.local.response["http_status_code"] = get_max_http_status_code(output.get("errors")) 40 | errors = [] 41 | for err in output.errors: 42 | if isinstance(err, GraphQLError): 43 | err = err.formatted 44 | err['message'] = strip_html_tags(err.get("message")) 45 | errors.append(err) 46 | output.errors = errors 47 | 48 | 49 | def get_query(): 50 | """ 51 | Gets Query details as per the specs in https://graphql.org/learn/serving-over-http/ 52 | """ 53 | 54 | query = None 55 | variables = None 56 | operation_name = None 57 | if not hasattr(frappe.local, "request"): 58 | return query, variables, operation_name 59 | 60 | from werkzeug.wrappers import Request 61 | request: Request = frappe.local.request 62 | content_type = request.content_type or "" 63 | 64 | if request.method == "GET": 65 | query = frappe.safe_decode(request.args["query"]) 66 | variables = frappe.safe_decode(request.args["variables"]) 67 | operation_name = frappe.safe_decode(request.args["operation_name"]) 68 | elif request.method == "POST": 69 | # raise Exception("Please send in application/json") 70 | if "application/json" in content_type: 71 | graphql_request = frappe.parse_json(request.get_data(as_text=True)) 72 | query = graphql_request.query 73 | variables = graphql_request.variables 74 | operation_name = graphql_request.operationName 75 | 76 | elif "multipart/form-data" in content_type: 77 | # Follows the spec here: https://github.com/jaydenseric/graphql-multipart-request-spec 78 | # This could be used for file uploads, single / multiple 79 | operations = frappe.parse_json(request.form.get("operations")) 80 | query = operations.get("query") 81 | variables = operations.get("variables") 82 | operation_name = operations.get("operationName") 83 | 84 | files_map = frappe.parse_json(request.form.get("map")) 85 | for file_key in files_map: 86 | file_instances = files_map[file_key] 87 | for file_instance in file_instances: 88 | path = file_instance.split(".") 89 | obj = operations[path.pop(0)] 90 | while len(path) > 1: 91 | obj = obj[path.pop(0)] 92 | 93 | obj[path.pop(0)] = file_key 94 | 95 | return query, variables, operation_name 96 | 97 | 98 | def get_max_http_status_code(errors: List[GraphQLError]): 99 | http_status_code = 400 100 | for error in errors: 101 | exc = error.original_error 102 | 103 | if not exc: 104 | continue 105 | 106 | exc_status = getattr(exc, "http_status_code", 400) 107 | if exc_status > http_status_code: 108 | http_status_code = exc_status 109 | 110 | return http_status_code 111 | 112 | 113 | def log_error(query, variables, operation_name, output): 114 | import traceback as tb 115 | tracebacks = [] 116 | for idx, err in enumerate(output.errors): 117 | if not isinstance(err, GraphQLError): 118 | continue 119 | 120 | exc = err.original_error 121 | if not exc: 122 | continue 123 | tracebacks.append( 124 | f"GQLError #{idx}\n" 125 | + f"Http Status Code: {getattr(exc, 'http_status_code', 500)}\n" 126 | + f"{str(err)}\n\n" 127 | + f"{''.join(tb.format_exception(exc, exc, exc.__traceback__))}" 128 | ) 129 | 130 | tracebacks.append(f"Frappe Traceback: \n{frappe.get_traceback()}") 131 | if frappe.conf.get("developer_mode"): 132 | frappe.errprint(tracebacks) 133 | 134 | tracebacks = "\n==========================================\n".join(tracebacks) 135 | if frappe.conf.get("developer_mode"): 136 | print(tracebacks) 137 | error_log = frappe.new_doc("GraphQL Error Log") 138 | error_log.update(frappe._dict( 139 | title="GraphQL API Error", 140 | operation_name=get_operation_name(query, operation_name), 141 | query=query, 142 | variables=frappe.as_json(get_masked_variables(query, variables)) if variables else None, 143 | output=frappe.as_json(output), 144 | traceback=tracebacks 145 | )) 146 | error_log.insert(ignore_permissions=True) 147 | -------------------------------------------------------------------------------- /frappe_graphql/cache.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from frappe_graphql.utils.resolver.utils import SINGULAR_DOCTYPE_MAP_REDIS_KEY, \ 4 | PLURAL_DOCTYPE_MAP_REDIS_KEY 5 | 6 | 7 | def clear_cache(): 8 | frappe.cache().delete_value(keys=[ 9 | SINGULAR_DOCTYPE_MAP_REDIS_KEY, 10 | PLURAL_DOCTYPE_MAP_REDIS_KEY 11 | ]) 12 | -------------------------------------------------------------------------------- /frappe_graphql/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from os import getcwd, path 2 | import click 3 | import frappe 4 | from frappe_graphql.utils.generate_sdl import make_doctype_sdl_files 5 | from frappe.commands import pass_context, get_site 6 | 7 | 8 | @click.group() 9 | def graphql(): 10 | pass 11 | 12 | 13 | @click.command("generate_sdl") 14 | @click.option("--output-dir", "-o", help="Directory to which to generate the SDLs") 15 | @click.option("--app", help="Name of the app whose doctype sdls need to be generated") 16 | @click.option("--module", "-m", multiple=True, 17 | help="Name of the module whose doctype sdls need to be generated") 18 | @click.option("--doctype", "-dt", multiple=True, 19 | help="Doctype to generate sdls for. You can specify multiple") 20 | @click.option("--ignore-custom-fields", is_flag=True, default=False, 21 | help="Ignore custom fields generation") 22 | @click.option("--disable-enum-select-fields", is_flag=True, default=False, 23 | help="Disable generating GQLEnums for Frappe Select DocFields") 24 | 25 | @pass_context 26 | def generate_sdl( 27 | context, output_dir=None, app=None, module=None, doctype=None, 28 | ignore_custom_fields=False, disable_enum_select_fields=False 29 | ): 30 | site = get_site(context=context) 31 | try: 32 | frappe.init(site=site) 33 | frappe.connect() 34 | target_dir = frappe.get_site_path("doctype_sdls") 35 | if output_dir: 36 | if not path.isabs(output_dir): 37 | target_dir = path.abspath( 38 | path.join(getcwd(), "../apps", output_dir)) 39 | else: 40 | target_dir = output_dir 41 | target_dir = path.abspath(target_dir) 42 | print("Generating in Directory: " + target_dir) 43 | make_doctype_sdl_files( 44 | target_dir=target_dir, 45 | app=app, 46 | modules=list(module), 47 | doctypes=list(doctype), 48 | ignore_custom_fields=ignore_custom_fields, 49 | disable_enum_select_fields=disable_enum_select_fields 50 | ) 51 | finally: 52 | frappe.destroy() 53 | 54 | 55 | graphql.add_command(generate_sdl) 56 | commands = [graphql] 57 | -------------------------------------------------------------------------------- /frappe_graphql/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_graphql/fbe04b7d55eb13f72d4b8ceb451335fd7eaac6a0/frappe_graphql/config/__init__.py -------------------------------------------------------------------------------- /frappe_graphql/config/desktop.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from frappe import _ 4 | 5 | def get_data(): 6 | return [ 7 | { 8 | "module_name": "Frappe Graphql", 9 | "color": "grey", 10 | "icon": "octicon octicon-file-directory", 11 | "type": "module", 12 | "label": _("Frappe Graphql") 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /frappe_graphql/config/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for docs 3 | """ 4 | 5 | # source_link = "https://github.com/[org_name]/frappe_graphql" 6 | # docs_base_url = "https://[org_name].github.io/frappe_graphql" 7 | # headline = "App that does everything" 8 | # sub_heading = "Yes, you got that right the first time, everything" 9 | 10 | def get_context(context): 11 | context.brand_html = "Frappe Graphql" 12 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_graphql/fbe04b7d55eb13f72d4b8ceb451335fd7eaac6a0/frappe_graphql/frappe_graphql/__init__.py -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_graphql/fbe04b7d55eb13f72d4b8ceb451335fd7eaac6a0/frappe_graphql/frappe_graphql/doctype/__init__.py -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/doctype/graphql_error_log/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_graphql/fbe04b7d55eb13f72d4b8ceb451335fd7eaac6a0/frappe_graphql/frappe_graphql/doctype/graphql_error_log/__init__.py -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/doctype/graphql_error_log/graphql_error_log.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Leam Technology Systems and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('GraphQL Error Log', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/doctype/graphql_error_log/graphql_error_log.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "GQL-ERR-.#####", 4 | "creation": "2021-04-27 22:42:24.465758", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "operation_name", 10 | "seen", 11 | "query", 12 | "variables", 13 | "section_break_5", 14 | "output", 15 | "traceback" 16 | ], 17 | "fields": [ 18 | { 19 | "fieldname": "operation_name", 20 | "fieldtype": "Data", 21 | "label": "Operation Name", 22 | "read_only": 1 23 | }, 24 | { 25 | "fieldname": "query", 26 | "fieldtype": "Code", 27 | "in_list_view": 1, 28 | "label": "Query / Mutation", 29 | "read_only": 1, 30 | "reqd": 1 31 | }, 32 | { 33 | "fieldname": "variables", 34 | "fieldtype": "Code", 35 | "label": "Variables", 36 | "read_only": 1 37 | }, 38 | { 39 | "fieldname": "section_break_5", 40 | "fieldtype": "Section Break" 41 | }, 42 | { 43 | "fieldname": "output", 44 | "fieldtype": "Code", 45 | "label": "Output", 46 | "read_only": 1 47 | }, 48 | { 49 | "fieldname": "traceback", 50 | "fieldtype": "Code", 51 | "label": "Traceback", 52 | "read_only": 1 53 | }, 54 | { 55 | "fieldname": "seen", 56 | "fieldtype": "Int", 57 | "hidden": 1, 58 | "label": "Seen", 59 | "read_only": 1 60 | } 61 | ], 62 | "in_create": 1, 63 | "index_web_pages_for_search": 1, 64 | "links": [], 65 | "modified": "2021-04-28 14:21:38.770483", 66 | "modified_by": "Administrator", 67 | "module": "Frappe Graphql", 68 | "name": "GraphQL Error Log", 69 | "owner": "Administrator", 70 | "permissions": [ 71 | { 72 | "create": 1, 73 | "delete": 1, 74 | "email": 1, 75 | "export": 1, 76 | "print": 1, 77 | "read": 1, 78 | "report": 1, 79 | "role": "System Manager", 80 | "share": 1, 81 | "write": 1 82 | } 83 | ], 84 | "sort_field": "modified", 85 | "sort_order": "DESC", 86 | "title_field": "operation_name" 87 | } -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/doctype/graphql_error_log/graphql_error_log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Leam Technology Systems and contributors 3 | # For license information, please see license.txt 4 | 5 | from __future__ import unicode_literals 6 | import frappe 7 | from frappe.model.document import Document 8 | 9 | 10 | class GraphQLErrorLog(Document): 11 | def onload(self): 12 | if not self.seen: 13 | self.db_set('seen', 1, update_modified=0) 14 | frappe.db.commit() 15 | 16 | 17 | def set_old_logs_as_seen(): 18 | # set logs as seen 19 | frappe.db.sql("""UPDATE `tabGraphQL Error Log` SET `seen`=1 20 | WHERE `seen`=0 AND `creation` < (NOW() - INTERVAL '7' DAY)""") 21 | 22 | 23 | @frappe.whitelist() 24 | def clear_error_logs(): 25 | '''Flush all Error Logs''' 26 | frappe.only_for('System Manager') 27 | frappe.db.sql('''DELETE FROM `tabGraphQL Error Log`''') 28 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/doctype/graphql_error_log/graphql_error_log_list.js: -------------------------------------------------------------------------------- 1 | frappe.listview_settings['GraphQL Error Log'] = { 2 | add_fields: ["seen"], 3 | get_indicator: function(doc) { 4 | if(cint(doc.seen)) { 5 | return [__("Seen"), "green", "seen,=,1"]; 6 | } else { 7 | return [__("Not Seen"), "red", "seen,=,0"]; 8 | } 9 | }, 10 | order_by: "seen asc, modified desc", 11 | onload: function(listview) { 12 | listview.page.add_menu_item(__("Clear Logs"), function() { 13 | frappe.call({ 14 | method:'frappe_graphql.frappe_graphql.doctype.graphql_error_log.graphql_error_log.clear_error_logs', 15 | callback: function() { 16 | listview.refresh(); 17 | } 18 | }); 19 | }); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/doctype/graphql_error_log/test_graphql_error_log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Leam Technology Systems and Contributors 3 | # See license.txt 4 | from __future__ import unicode_literals 5 | 6 | # import frappe 7 | import unittest 8 | 9 | 10 | class TestGraphQLErrorLog(unittest.TestCase): 11 | pass 12 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/mutations/delete_doc.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLSchema, GraphQLResolveInfo 2 | 3 | import frappe 4 | 5 | 6 | def bind(schema: GraphQLSchema): 7 | schema.mutation_type.fields["deleteDoc"].resolve = delete_doc_resolver 8 | 9 | 10 | def delete_doc_resolver(obj, info: GraphQLResolveInfo, **kwargs): 11 | doctype = kwargs.get("doctype") 12 | name = kwargs.get("name") 13 | doc = frappe.get_doc(doctype, name) 14 | doc.delete() 15 | return frappe._dict({ 16 | "doctype": doctype, 17 | "name": name, 18 | "success": True 19 | }) 20 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/mutations/save_doc.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLSchema, GraphQLResolveInfo, GraphQLObjectType 2 | 3 | import frappe 4 | from frappe.model.meta import is_single 5 | 6 | 7 | def bind(schema: GraphQLSchema): 8 | schema.mutation_type.fields["saveDoc"].resolve = save_doc_resolver 9 | 10 | # setting type resolver for Abstract type (interface) 11 | SAVE_DOC_TYPE: GraphQLObjectType = schema.mutation_type.fields["saveDoc"].type 12 | 13 | def resolve_dt(obj, info, *args, **kwargs): 14 | return obj.doctype.replace(" ", "") 15 | 16 | SAVE_DOC_TYPE.fields["doc"].type.of_type.resolve_type = resolve_dt 17 | 18 | 19 | def save_doc_resolver(obj, info: GraphQLResolveInfo, **kwargs): 20 | new_doc = frappe.parse_json(kwargs.get("doc")) 21 | new_doc.doctype = kwargs.get("doctype") 22 | 23 | if is_single(new_doc.doctype): 24 | new_doc.name = new_doc.doctype 25 | 26 | if new_doc.name and frappe.db.exists(new_doc.doctype, new_doc.name): 27 | doc = frappe.get_doc(new_doc.doctype, new_doc.name) 28 | else: 29 | doc = frappe.new_doc(new_doc.doctype) 30 | doc.update(new_doc) 31 | doc.save() 32 | doc.reload() 33 | 34 | return { 35 | "doctype": doc.doctype, 36 | "name": doc.name, 37 | "doc": doc.as_dict() 38 | } 39 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/mutations/set_value.py: -------------------------------------------------------------------------------- 1 | from frappe.model import table_fields 2 | from graphql import GraphQLSchema, GraphQLResolveInfo, GraphQLObjectType 3 | 4 | import frappe 5 | 6 | 7 | def bind(schema: GraphQLSchema): 8 | schema.mutation_type.fields["setValue"].resolve = set_value_resolver 9 | 10 | # setting type resolver for Abstract type (interface) 11 | SET_VALUE_TYPE: GraphQLObjectType = schema.mutation_type.fields[ 12 | "setValue"].type 13 | 14 | def resolve_dt(obj, info, *args, **kwargs): 15 | return obj.doctype.replace(" ", "") 16 | 17 | SET_VALUE_TYPE.fields["doc"].type.of_type.resolve_type = resolve_dt 18 | 19 | 20 | def set_value_resolver(obj, info: GraphQLResolveInfo, **kwargs): 21 | doctype = kwargs.get("doctype") 22 | name = kwargs.get("name") 23 | value = kwargs.get('value') 24 | fieldname = kwargs.get("fieldname") 25 | if frappe.get_meta(doctype).get_field(fieldname).fieldtype \ 26 | in table_fields: 27 | value = frappe.parse_json(value) 28 | frappe.set_value( 29 | doctype=doctype, 30 | docname=name, 31 | fieldname=fieldname, 32 | value=value) 33 | frappe.clear_document_cache(doctype, name) 34 | doc = frappe.get_doc(doctype, name).as_dict() 35 | return { 36 | "doctype": doctype, 37 | "name": name, 38 | "fieldname": kwargs.get("fieldname"), 39 | "value": kwargs.get("value"), 40 | "doc": doc 41 | } 42 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/mutations/subscription_keepalive.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLSchema, GraphQLResolveInfo 2 | 3 | import frappe 4 | from frappe_graphql.utils.subscriptions import subscription_keepalive 5 | 6 | 7 | def bind(schema: GraphQLSchema): 8 | schema.mutation_type.fields["subscriptionKeepAlive"].resolve = subscription_keepalive_resolver 9 | 10 | 11 | def subscription_keepalive_resolver(obj, info: GraphQLResolveInfo, **kwargs): 12 | response = frappe._dict( 13 | error=None, 14 | success=False 15 | ) 16 | 17 | try: 18 | r = subscription_keepalive( 19 | subscription=kwargs.get("subscription"), 20 | subscription_id=kwargs.get("subscription_id") 21 | ) 22 | response.success = True 23 | response.subscription_id = kwargs.get("subscription_id") 24 | response.subscribed_at = r.subscribed_at 25 | response.variables = frappe.as_json( 26 | r.variables) if not isinstance( 27 | r.variables, str) else r.variables 28 | except BaseException as e: 29 | if "is not a valid subscription" in str(e): 30 | response.error = "INVALID_SUBSCRIPTION" 31 | else: 32 | response.error = "SUBSCRIPTION_NOT_FOUND" 33 | 34 | return response 35 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/mutations/upload_file.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLSchema, GraphQLResolveInfo 2 | 3 | 4 | def bind(schema: GraphQLSchema): 5 | schema.mutation_type.fields["uploadFile"].resolve = file_upload_resolver 6 | 7 | 8 | def file_upload_resolver(obj, info: GraphQLResolveInfo, **kwargs): 9 | from frappe_graphql.utils.file import make_file_document 10 | 11 | file_doc = make_file_document( 12 | file_key=kwargs.get("file"), 13 | is_private=1 if kwargs.get("is_private") else 0, 14 | doctype=kwargs.get("attached_to_doctype"), 15 | docname=kwargs.get("attached_to_name"), 16 | fieldname=kwargs.get("fieldname"), 17 | ) 18 | 19 | return file_doc 20 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/queries/ping.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLSchema 2 | 3 | 4 | def bind(schema: GraphQLSchema): 5 | schema.query_type.fields["ping"].resolve = lambda obj, info: "pong" 6 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/subscription/doc_events.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from graphql import GraphQLSchema, GraphQLResolveInfo 3 | from frappe_graphql import setup_subscription, get_consumers, notify_consumers, get_schema 4 | from frappe_graphql.utils.resolver.utils import get_singular_doctype 5 | 6 | 7 | def bind(schema: GraphQLSchema): 8 | schema.subscription_type.fields["doc_events"].resolve = doc_events_resolver 9 | 10 | 11 | def doc_events_resolver(obj, info: GraphQLResolveInfo, **kwargs): 12 | if frappe.session.user == "Guest": 13 | frappe.throw("Not Allowed for Guests") 14 | 15 | return setup_subscription( 16 | subscription="doc_events", 17 | info=info, 18 | variables=kwargs 19 | ) 20 | 21 | 22 | def on_change(doc, method=None): 23 | flags = ["in_migrate", "in_install", "in_patch", 24 | "in_import", "in_setup_wizard", "in_uninstall"] 25 | if any([getattr(frappe.flags, f, None) for f in flags]): 26 | return 27 | 28 | subscription_ids = [] 29 | for consumer in get_consumers("doc_events"): 30 | doctypes = consumer.variables.get("doctypes", []) 31 | if len(doctypes) and doc.doctype not in doctypes: 32 | continue 33 | 34 | subscription_ids.append(consumer.subscription_id) 35 | 36 | if not len(subscription_ids): 37 | return 38 | 39 | # Verify DocType type has beed defined in SDL 40 | schema = get_schema() 41 | if not schema.get_type(get_singular_doctype(doc.doctype)): 42 | return 43 | 44 | frappe.enqueue( 45 | notify_consumers, 46 | enqueue_after_commit=True, 47 | subscription="doc_events", 48 | subscription_ids=subscription_ids, 49 | data=frappe._dict( 50 | event="on_change", 51 | doctype=doc.doctype, 52 | name=doc.name, 53 | document=frappe._dict( 54 | doctype=doc.doctype, 55 | name=doc.name 56 | ), 57 | triggered_by=frappe._dict( 58 | doctype="User", 59 | name=frappe.session.user 60 | ) 61 | ) 62 | ) 63 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/docfield.graphql: -------------------------------------------------------------------------------- 1 | type DocField implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | label: String 17 | fieldtype: DocFieldTypeSelectOptions! 18 | fieldname: String 19 | precision: String 20 | length: Int 21 | non_negative: Int 22 | hide_days: Int 23 | hide_seconds: Int 24 | reqd: Int 25 | search_index: Int 26 | in_list_view: Int 27 | in_standard_filter: Int 28 | in_global_search: Int 29 | in_preview: Int 30 | allow_in_quick_entry: Int 31 | bold: Int 32 | translatable: Int 33 | collapsible: Int 34 | collapsible_depends_on: String 35 | options: String 36 | default: String 37 | fetch_from: String 38 | fetch_if_empty: Int 39 | depends_on: String 40 | hidden: Int 41 | read_only: Int 42 | unique: Int 43 | set_only_once: Int 44 | allow_bulk_edit: Int 45 | permlevel: Int 46 | ignore_user_permissions: Int 47 | allow_on_submit: Int 48 | report_hide: Int 49 | remember_last_selected_value: Int 50 | ignore_xss_filter: Int 51 | hide_border: Int 52 | mandatory_depends_on: String 53 | read_only_depends_on: String 54 | in_filter: Int 55 | no_copy: Int 56 | print_hide: Int 57 | print_hide_if_no_value: Int 58 | print_width: String 59 | width: String 60 | columns: Int 61 | description: String 62 | oldfieldname: String 63 | oldfieldtype: String 64 | } 65 | 66 | enum DocFieldTypeSelectOptions { 67 | ATTACH 68 | ATTACH_IMAGE 69 | BARCODE 70 | BUTTON 71 | CHECK 72 | CODE 73 | COLOR 74 | COLUMN_BREAK 75 | CURRENCY 76 | DATA 77 | DATE 78 | DATETIME 79 | DURATION 80 | DYNAMIC_LINK 81 | FLOAT 82 | FOLD 83 | GEOLOCATION 84 | HEADING 85 | HTML 86 | HTML_EDITOR 87 | ICON 88 | IMAGE 89 | INT 90 | LINK 91 | LONG_TEXT 92 | MARKDOWN_EDITOR 93 | PASSWORD 94 | PERCENT 95 | READ_ONLY 96 | RATING 97 | SECTION_BREAK 98 | SELECT 99 | SMALL_TEXT 100 | TABLE 101 | TABLE_MULTISELECT 102 | TEXT 103 | TEXT_EDITOR 104 | TIME 105 | SIGNATURE 106 | } -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/docperm.graphql: -------------------------------------------------------------------------------- 1 | type DocPerm implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | role: Role! 17 | role__name: String 18 | if_owner: Int 19 | permlevel: Int 20 | select: Int 21 | read: Int 22 | write: Int 23 | create: Int 24 | delete: Int 25 | submit: Int 26 | cancel: Int 27 | amend: Int 28 | report: Int 29 | export: Int 30 | import: Int 31 | set_user_permissions: Int 32 | share: Int 33 | print: Int 34 | email: Int 35 | } -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/doctype.graphql: -------------------------------------------------------------------------------- 1 | type DocType implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | module: ModuleDef! 17 | module__name: String 18 | is_submittable: Int 19 | istable: Int 20 | issingle: Int 21 | is_tree: Int 22 | editable_grid: Int 23 | quick_entry: Int 24 | track_changes: Int 25 | track_seen: Int 26 | track_views: Int 27 | custom: Int 28 | beta: Int 29 | is_virtual: Int 30 | fields: [DocField!]! 31 | autoname: String 32 | name_case: DocTypeNameCaseSelectOptions 33 | allow_rename: Int 34 | description: String 35 | documentation: String 36 | image_field: String 37 | timeline_field: String 38 | nsm_parent_field: String 39 | max_attachments: Int 40 | hide_toolbar: Int 41 | allow_copy: Int 42 | allow_import: Int 43 | allow_events_in_timeline: Int 44 | allow_auto_repeat: Int 45 | title_field: String 46 | search_fields: String 47 | default_print_format: String 48 | sort_field: String 49 | sort_order: DocTypeDefaultSortOrderSelectOptions 50 | document_type: DocTypeShowInModuleSectionSelectOptions 51 | icon: String 52 | color: String 53 | show_preview_popup: Int 54 | show_name_in_global_search: Int 55 | default_email_template__name: String 56 | email_append_to: Int 57 | sender_field: String 58 | subject_field: String 59 | permissions: [DocPerm!]! 60 | restrict_to_domain: Domain 61 | restrict_to_domain__name: String 62 | read_only: Int 63 | in_create: Int 64 | actions: [DocTypeAction!]! 65 | links: [DocTypeLink!]! 66 | has_web_view: Int 67 | allow_guest_to_view: Int 68 | index_web_pages_for_search: Int 69 | route: String 70 | is_published_field: String 71 | engine: DocTypeDatabaseEngineSelectOptions 72 | } 73 | 74 | enum DocTypeNameCaseSelectOptions { 75 | TITLE_CASE 76 | UPPER_CASE 77 | } 78 | 79 | enum DocTypeDefaultSortOrderSelectOptions { 80 | ASC 81 | DESC 82 | } 83 | 84 | enum DocTypeShowInModuleSectionSelectOptions { 85 | DOCUMENT 86 | SETUP 87 | SYSTEM 88 | OTHER 89 | } 90 | 91 | enum DocTypeDatabaseEngineSelectOptions { 92 | INNODB 93 | MYISAM 94 | } 95 | 96 | enum DocTypeSortField { 97 | NAME 98 | CREATION 99 | MODIFIED 100 | MODULE 101 | } 102 | 103 | input DocTypeSortingInput { 104 | direction: SortDirection! 105 | field: DocTypeSortField! 106 | } 107 | 108 | type DocTypeCountableEdge { 109 | cursor: String! 110 | node: DocType! 111 | } 112 | 113 | type DocTypeCountableConnection { 114 | pageInfo: PageInfo! 115 | totalCount: Int 116 | edges: [DocTypeCountableEdge!]! 117 | } 118 | 119 | extend type Query { 120 | DocType(name: String!): DocType! 121 | DocTypes(filter: [DBFilterInput], sortBy: DocTypeSortingInput, before: String, after: String, first: Int, last: Int): DocTypeCountableConnection! 122 | } 123 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/doctype_action.graphql: -------------------------------------------------------------------------------- 1 | type DocTypeAction implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | label: String! 17 | action_type: DocTypeActionActionTypeSelectOptions! 18 | action: String! 19 | group: String 20 | hidden: Int 21 | custom: Int 22 | } 23 | 24 | enum DocTypeActionActionTypeSelectOptions { 25 | SERVER_ACTION 26 | ROUTE 27 | } -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/doctype_link.graphql: -------------------------------------------------------------------------------- 1 | type DocTypeLink implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | link_doctype: DocType! 17 | link_doctype__name: String 18 | link_fieldname: String! 19 | parent_doctype: DocType 20 | parent_doctype__name: String 21 | table_fieldname: String 22 | group: String 23 | hidden: Int 24 | is_child_table: Int 25 | custom: Int 26 | } -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/domain.graphql: -------------------------------------------------------------------------------- 1 | type Domain implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | domain: String! 17 | } 18 | 19 | enum DomainSortField { 20 | NAME 21 | CREATION 22 | MODIFIED 23 | DOMAIN 24 | } 25 | 26 | input DomainSortingInput { 27 | direction: SortDirection! 28 | field: DomainSortField! 29 | } 30 | 31 | type DomainCountableEdge { 32 | cursor: String! 33 | node: Domain! 34 | } 35 | 36 | type DomainCountableConnection { 37 | pageInfo: PageInfo! 38 | totalCount: Int 39 | edges: [DomainCountableEdge!]! 40 | } 41 | 42 | extend type Query { 43 | Domain(name: String!): Domain! 44 | Domains(filter: [DBFilterInput], sortBy: DomainSortingInput, before: String, after: String, first: Int, last: Int): DomainCountableConnection! 45 | } 46 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/dynamic_link.graphql: -------------------------------------------------------------------------------- 1 | type DynamicLink implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | link_doctype: DocType! 17 | link_doctype__name: String 18 | link_name: BaseDocType! 19 | link_name__name: String 20 | link_title: String 21 | } -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/file.graphql: -------------------------------------------------------------------------------- 1 | type File implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | file_name: String 17 | is_private: Int 18 | is_home_folder: Int 19 | is_attachments_folder: Int 20 | file_size: Int 21 | file_url: String 22 | thumbnail_url: String 23 | folder: File 24 | folder__name: String 25 | is_folder: Int 26 | attached_to_doctype: DocType 27 | attached_to_doctype__name: String 28 | attached_to_name: String 29 | attached_to_field: String 30 | old_parent: String 31 | content_hash: String 32 | uploaded_to_dropbox: Int 33 | uploaded_to_google_drive: Int 34 | } 35 | 36 | enum FileSortField { 37 | NAME 38 | CREATION 39 | MODIFIED 40 | IS_HOME_FOLDER 41 | ATTACHED_TO_DOCTYPE 42 | ATTACHED_TO_NAME 43 | } 44 | 45 | input FileSortingInput { 46 | direction: SortDirection! 47 | field: FileSortField! 48 | } 49 | 50 | type FileCountableEdge { 51 | cursor: String! 52 | node: File! 53 | } 54 | 55 | type FileCountableConnection { 56 | pageInfo: PageInfo! 57 | totalCount: Int 58 | edges: [FileCountableEdge!]! 59 | } 60 | 61 | extend type Query { 62 | File(name: String!): File! 63 | Files(filter: [DBFilterInput], sortBy: FileSortingInput, before: String, after: String, first: Int, last: Int): FileCountableConnection! 64 | } 65 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/gender.graphql: -------------------------------------------------------------------------------- 1 | type Gender implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | gender: String 17 | } 18 | 19 | enum GenderSortField { 20 | NAME 21 | CREATION 22 | MODIFIED 23 | } 24 | 25 | input GenderSortingInput { 26 | direction: SortDirection! 27 | field: GenderSortField! 28 | } 29 | 30 | type GenderCountableEdge { 31 | cursor: String! 32 | node: Gender! 33 | } 34 | 35 | type GenderCountableConnection { 36 | pageInfo: PageInfo! 37 | totalCount: Int 38 | edges: [GenderCountableEdge!]! 39 | } 40 | 41 | extend type Query { 42 | Gender(name: String!): Gender! 43 | Genders(filter: [DBFilterInput], sortBy: GenderSortingInput, before: String, after: String, first: Int, last: Int): GenderCountableConnection! 44 | } 45 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/has_role.graphql: -------------------------------------------------------------------------------- 1 | type HasRole implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | role: Role 17 | role__name: String 18 | } -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/language.graphql: -------------------------------------------------------------------------------- 1 | type Language implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | enabled: Int 17 | language_code: String! 18 | language_name: String! 19 | flag: String 20 | based_on: Language 21 | based_on__name: String 22 | } 23 | 24 | enum LanguageSortField { 25 | NAME 26 | CREATION 27 | MODIFIED 28 | LANGUAGE_CODE 29 | } 30 | 31 | input LanguageSortingInput { 32 | direction: SortDirection! 33 | field: LanguageSortField! 34 | } 35 | 36 | type LanguageCountableEdge { 37 | cursor: String! 38 | node: Language! 39 | } 40 | 41 | type LanguageCountableConnection { 42 | pageInfo: PageInfo! 43 | totalCount: Int 44 | edges: [LanguageCountableEdge!]! 45 | } 46 | 47 | extend type Query { 48 | Language(name: String!): Language! 49 | Languages(filter: [DBFilterInput], sortBy: LanguageSortingInput, before: String, after: String, first: Int, last: Int): LanguageCountableConnection! 50 | } 51 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/module_def.graphql: -------------------------------------------------------------------------------- 1 | type ModuleDef implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | module_name: String! 17 | custom: Int 18 | app_name: String! 19 | restrict_to_domain: Domain 20 | restrict_to_domain__name: String 21 | } 22 | 23 | enum ModuleDefSortField { 24 | NAME 25 | CREATION 26 | MODIFIED 27 | MODULE_NAME 28 | } 29 | 30 | input ModuleDefSortingInput { 31 | direction: SortDirection! 32 | field: ModuleDefSortField! 33 | } 34 | 35 | type ModuleDefCountableEdge { 36 | cursor: String! 37 | node: ModuleDef! 38 | } 39 | 40 | type ModuleDefCountableConnection { 41 | pageInfo: PageInfo! 42 | totalCount: Int 43 | edges: [ModuleDefCountableEdge!]! 44 | } 45 | 46 | extend type Query { 47 | ModuleDef(name: String!): ModuleDef! 48 | ModuleDefs(filter: [DBFilterInput], sortBy: ModuleDefSortingInput, before: String, after: String, first: Int, last: Int): ModuleDefCountableConnection! 49 | } 50 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/role.graphql: -------------------------------------------------------------------------------- 1 | type Role implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | role_name: String! 17 | home_page: String 18 | restrict_to_domain: Domain 19 | restrict_to_domain__name: String 20 | disabled: Int 21 | is_custom: Int 22 | desk_access: Int 23 | two_factor_auth: Int 24 | search_bar: Int 25 | notifications: Int 26 | list_sidebar: Int 27 | bulk_actions: Int 28 | view_switcher: Int 29 | form_sidebar: Int 30 | timeline: Int 31 | dashboard: Int 32 | } 33 | 34 | enum RoleSortField { 35 | NAME 36 | CREATION 37 | MODIFIED 38 | ROLE_NAME 39 | } 40 | 41 | input RoleSortingInput { 42 | direction: SortDirection! 43 | field: RoleSortField! 44 | } 45 | 46 | type RoleCountableEdge { 47 | cursor: String! 48 | node: Role! 49 | } 50 | 51 | type RoleCountableConnection { 52 | pageInfo: PageInfo! 53 | totalCount: Int 54 | edges: [RoleCountableEdge!]! 55 | } 56 | 57 | extend type Query { 58 | Role(name: String!): Role! 59 | Roles(filter: [DBFilterInput], sortBy: RoleSortingInput, before: String, after: String, first: Int, last: Int): RoleCountableConnection! 60 | } 61 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/role_profile.graphql: -------------------------------------------------------------------------------- 1 | type RoleProfile implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | role_profile: String! 17 | roles: [HasRole!]! 18 | } 19 | 20 | enum RoleProfileSortField { 21 | NAME 22 | CREATION 23 | MODIFIED 24 | ROLE_PROFILE 25 | } 26 | 27 | input RoleProfileSortingInput { 28 | direction: SortDirection! 29 | field: RoleProfileSortField! 30 | } 31 | 32 | type RoleProfileCountableEdge { 33 | cursor: String! 34 | node: RoleProfile! 35 | } 36 | 37 | type RoleProfileCountableConnection { 38 | pageInfo: PageInfo! 39 | totalCount: Int 40 | edges: [RoleProfileCountableEdge!]! 41 | } 42 | 43 | extend type Query { 44 | RoleProfile(name: String!): RoleProfile! 45 | RoleProfiles(filter: [DBFilterInput], sortBy: RoleProfileSortingInput, before: String, after: String, first: Int, last: Int): RoleProfileCountableConnection! 46 | } 47 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/root.graphql: -------------------------------------------------------------------------------- 1 | scalar Upload 2 | scalar Password 3 | 4 | schema { 5 | query: Query 6 | mutation: Mutation 7 | subscription: Subscription 8 | } 9 | 10 | interface BaseDocType { 11 | doctype: String 12 | name: String 13 | owner: User! 14 | creation: String 15 | modified: String 16 | modified_by: User! 17 | parent: BaseDocType 18 | parent__name: String 19 | parentfield: String 20 | parenttype: String 21 | idx: Int 22 | docstatus: Int 23 | owner__name: String! 24 | modified_by__name: String! 25 | } 26 | 27 | interface BaseSubscription { 28 | subscription_id: String! 29 | } 30 | 31 | 32 | type SET_VALUE_TYPE { 33 | doctype: String! 34 | name: String! 35 | fieldname: String! 36 | value: DOCFIELD_VALUE_TYPE! 37 | doc: BaseDocType! 38 | } 39 | 40 | 41 | """ 42 | Supports ID | Boolean | Int | String | Float | JSONString 43 | """ 44 | scalar DOCFIELD_VALUE_TYPE 45 | 46 | type SAVE_DOC_TYPE { 47 | doctype: String! 48 | name: String! 49 | doc: BaseDocType! 50 | } 51 | 52 | type DELETE_DOC_TYPE { 53 | doctype: String! 54 | name: String! 55 | success: Boolean! 56 | } 57 | 58 | type Mutation { 59 | 60 | # Basic Document Mutations 61 | setValue(doctype: String!, name: String!, fieldname: String!, value: DOCFIELD_VALUE_TYPE!): SET_VALUE_TYPE 62 | saveDoc(doctype: String!, doc: String!): SAVE_DOC_TYPE 63 | deleteDoc(doctype: String!, name: String!): DELETE_DOC_TYPE 64 | 65 | uploadFile(file: Upload!, is_private: Boolean, attached_to_doctype: String, 66 | attached_to_name: String, fieldname: String): File 67 | 68 | # Subscription KeepAlive 69 | subscriptionKeepAlive(subscription: String!, subscription_id: String!): SubscriptionInfo! 70 | } 71 | 72 | type Query { 73 | ping: String! 74 | } 75 | 76 | type Subscription { 77 | doc_events(doctypes: [String!]): DocEvent 78 | } 79 | 80 | enum DBFilterOperator { 81 | EQ 82 | NEQ 83 | LT 84 | GT 85 | LTE 86 | GTE 87 | LIKE 88 | NOT_LIKE 89 | } 90 | 91 | input DBFilterInput { 92 | fieldname: String! 93 | operator: DBFilterOperator 94 | value: String! 95 | } 96 | 97 | enum SortDirection { 98 | ASC 99 | DESC 100 | } 101 | 102 | type PageInfo { 103 | hasNextPage: Boolean! 104 | hasPreviousPage: Boolean! 105 | startCursor: String 106 | endCursor: String 107 | } 108 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/subscriptions.graphql: -------------------------------------------------------------------------------- 1 | enum SubscriptionError { 2 | INVALID_SUBSCRIPTION 3 | SUBSCRIPTION_NOT_FOUND 4 | } 5 | 6 | type SubscriptionInfo { 7 | error: SubscriptionError 8 | success: Boolean! 9 | subscription_id: String 10 | subscribed_at: String 11 | variables: String 12 | } 13 | 14 | type DocEvent implements BaseSubscription { 15 | doctype: String! 16 | name: String! 17 | event: String! 18 | document: BaseDocType! 19 | triggered_by: User! 20 | subscription_id: String! 21 | } 22 | -------------------------------------------------------------------------------- /frappe_graphql/frappe_graphql/types/user.graphql: -------------------------------------------------------------------------------- 1 | type User implements BaseDocType { 2 | doctype: String 3 | name: String 4 | owner: User! 5 | creation: String 6 | modified: String 7 | modified_by: User! 8 | parent: BaseDocType 9 | parentfield: String 10 | parenttype: String 11 | idx: Int 12 | docstatus: Int 13 | owner__name: String! 14 | modified_by__name: String! 15 | parent__name: String 16 | enabled: Int 17 | email: String! 18 | first_name: String! 19 | middle_name: String 20 | last_name: String 21 | full_name: String 22 | username: String 23 | language: Language 24 | language__name: String 25 | time_zone: String 26 | send_welcome_email: Int 27 | unsubscribed: Int 28 | user_image: String 29 | role_profile_name: RoleProfile 30 | role_profile_name__name: String 31 | roles: [HasRole!]! 32 | gender: Gender 33 | gender__name: String 34 | birth_date: String 35 | interest: String 36 | banner_image: String 37 | desk_theme: UserDeskThemeSelectOptions 38 | phone: String 39 | location: String 40 | bio: String 41 | mute_sounds: Int 42 | mobile_no: String 43 | new_password: Password 44 | logout_all_sessions: Int 45 | reset_password_key: String 46 | last_password_reset_date: String 47 | redirect_url: String 48 | document_follow_notify: Int 49 | document_follow_frequency: UserFrequencySelectOptions 50 | email_signature: String 51 | thread_notify: Int 52 | send_me_a_copy: Int 53 | allowed_in_mentions: Int 54 | module_profile__name: String 55 | home_settings: String 56 | simultaneous_sessions: Int 57 | restrict_ip: String 58 | last_ip: String 59 | login_after: Int 60 | user_type__name: String 61 | last_active: String 62 | login_before: Int 63 | bypass_restrict_ip_check_if_2fa_enabled: Int 64 | last_login: String 65 | last_known_versions: String 66 | api_key: String 67 | api_secret: Password 68 | } 69 | 70 | enum UserDeskThemeSelectOptions { 71 | LIGHT 72 | DARK 73 | } 74 | 75 | enum UserFrequencySelectOptions { 76 | HOURLY 77 | DAILY 78 | WEEKLY 79 | } 80 | 81 | enum UserSortField { 82 | NAME 83 | CREATION 84 | MODIFIED 85 | USERNAME 86 | MOBILE_NO 87 | API_KEY 88 | } 89 | 90 | input UserSortingInput { 91 | direction: SortDirection! 92 | field: UserSortField! 93 | } 94 | 95 | type UserCountableEdge { 96 | cursor: String! 97 | node: User! 98 | } 99 | 100 | type UserCountableConnection { 101 | pageInfo: PageInfo! 102 | totalCount: Int 103 | edges: [UserCountableEdge!]! 104 | } 105 | 106 | extend type Query { 107 | User(name: String!): User! 108 | Users(filter: [DBFilterInput], sortBy: UserSortingInput, before: String, after: String, first: Int, last: Int): UserCountableConnection! 109 | } 110 | -------------------------------------------------------------------------------- /frappe_graphql/graphql.py: -------------------------------------------------------------------------------- 1 | from graphql_sync_dataloaders import DeferredExecutionContext 2 | 3 | import frappe 4 | import graphql 5 | 6 | from frappe_graphql.utils.loader import get_schema 7 | 8 | 9 | @frappe.whitelist(allow_guest=True) 10 | def execute(query=None, variables=None, operation_name=None): 11 | result = graphql.graphql_sync( 12 | schema=get_schema(), 13 | source=query, 14 | variable_values=variables, 15 | operation_name=operation_name if operation_name else None, 16 | middleware=[frappe.get_attr(cmd) for cmd in frappe.get_hooks("graphql_middlewares")], 17 | context_value=frappe._dict(), 18 | execution_context_class=DeferredExecutionContext 19 | ) 20 | output = frappe._dict() 21 | for k in ("data", "errors"): 22 | if not getattr(result, k, None): 23 | continue 24 | output[k] = getattr(result, k) 25 | 26 | return output 27 | -------------------------------------------------------------------------------- /frappe_graphql/hooks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | # from . import __version__ as app_version 5 | 6 | app_name = "frappe_graphql" 7 | app_title = "Frappe Graphql" 8 | app_publisher = "Leam Technology Systems" 9 | app_description = "GraphQL API Layer for Frappe Framework" 10 | app_icon = "octicon octicon-file-directory" 11 | app_color = "grey" 12 | app_email = "info@leam.ae" 13 | app_license = "MIT" 14 | 15 | graphql_sdl_dir = [ 16 | "./frappe_graphql/frappe_graphql/frappe_graphql/types" 17 | ] 18 | 19 | scheduler_events = { 20 | "all": [ 21 | "frappe_graphql.utils.subscriptions.remove_inactive_consumers" 22 | ] 23 | } 24 | 25 | graphql_schema_processors = [ 26 | # Queries 27 | "frappe_graphql.frappe_graphql.queries.ping.bind", 28 | 29 | # Mutations 30 | "frappe_graphql.frappe_graphql.mutations.set_value.bind", 31 | "frappe_graphql.frappe_graphql.mutations.save_doc.bind", 32 | "frappe_graphql.frappe_graphql.mutations.delete_doc.bind", 33 | 34 | "frappe_graphql.frappe_graphql.mutations.upload_file.bind", 35 | "frappe_graphql.frappe_graphql.mutations.subscription_keepalive.bind", 36 | 37 | # Subscriptions 38 | "frappe_graphql.frappe_graphql.subscription.doc_events.bind", 39 | ] 40 | 41 | graphql_middlewares = ["frappe_graphql.utils.middlewares.disable_introspection_queries.disable_introspection_queries"] 42 | 43 | doc_events = { 44 | "*": { 45 | # Doc Events Subscription 46 | "on_change": "frappe_graphql.frappe_graphql.subscription.doc_events.on_change" 47 | } 48 | } 49 | 50 | # Includes in 51 | # ------------------ 52 | 53 | # include js, css files in header of desk.html 54 | # app_include_css = "/assets/frappe_graphql/css/frappe_graphql.css" 55 | # app_include_js = "/assets/frappe_graphql/js/frappe_graphql.js" 56 | 57 | # include js, css files in header of web template 58 | # web_include_css = "/assets/frappe_graphql/css/frappe_graphql.css" 59 | # web_include_js = "/assets/frappe_graphql/js/frappe_graphql.js" 60 | 61 | # include custom scss in every website theme (without file extension ".scss") 62 | # website_theme_scss = "frappe_graphql/public/scss/website" 63 | 64 | # include js, css files in header of web form 65 | # webform_include_js = {"doctype": "public/js/doctype.js"} 66 | # webform_include_css = {"doctype": "public/css/doctype.css"} 67 | 68 | # include js in page 69 | # page_js = {"page" : "public/js/file.js"} 70 | 71 | # include js in doctype views 72 | # doctype_js = {"doctype" : "public/js/doctype.js"} 73 | # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} 74 | # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} 75 | # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} 76 | 77 | # Home Pages 78 | # ---------- 79 | 80 | # application home page (will override Website Settings) 81 | # home_page = "login" 82 | 83 | # website user home page (by Role) 84 | # role_home_page = { 85 | # "Role": "home_page" 86 | # } 87 | 88 | # Generators 89 | # ---------- 90 | 91 | # automatically create page for each record of this doctype 92 | # website_generators = ["Web Page"] 93 | 94 | # Installation 95 | # ------------ 96 | 97 | # before_install = "frappe_graphql.install.before_install" 98 | # after_install = "frappe_graphql.install.after_install" 99 | 100 | # Desk Notifications 101 | # ------------------ 102 | # See frappe.core.notifications.get_notification_config 103 | 104 | # notification_config = "frappe_graphql.notifications.get_notification_config" 105 | 106 | # Permissions 107 | # ----------- 108 | # Permissions evaluated in scripted ways 109 | 110 | # permission_query_conditions = { 111 | # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", 112 | # } 113 | # 114 | # has_permission = { 115 | # "Event": "frappe.desk.doctype.event.event.has_permission", 116 | # } 117 | 118 | # DocType Class 119 | # --------------- 120 | # Override standard doctype classes 121 | 122 | # override_doctype_class = { 123 | # "ToDo": "custom_app.overrides.CustomToDo" 124 | # } 125 | 126 | # Document Events 127 | # --------------- 128 | # Hook on document methods and events 129 | 130 | # doc_events = { 131 | # "*": { 132 | # "on_update": "method", 133 | # "on_cancel": "method", 134 | # "on_trash": "method" 135 | # } 136 | # } 137 | 138 | # Scheduled Tasks 139 | # --------------- 140 | 141 | # scheduler_events = { 142 | # "all": [ 143 | # "frappe_graphql.tasks.all" 144 | # ], 145 | # "daily": [ 146 | # "frappe_graphql.tasks.daily" 147 | # ], 148 | # "hourly": [ 149 | # "frappe_graphql.tasks.hourly" 150 | # ], 151 | # "weekly": [ 152 | # "frappe_graphql.tasks.weekly" 153 | # ] 154 | # "monthly": [ 155 | # "frappe_graphql.tasks.monthly" 156 | # ] 157 | # } 158 | 159 | # Testing 160 | # ------- 161 | 162 | # before_tests = "frappe_graphql.install.before_tests" 163 | 164 | # Overriding Methods 165 | # ------------------------------ 166 | # 167 | override_whitelisted_methods = { 168 | "graphql": "frappe_graphql.api.execute_gql_query" 169 | } 170 | # 171 | # each overriding function accepts a `data` argument; 172 | # generated from the base implementation of the doctype dashboard, 173 | # along with any modifications made in other Frappe apps 174 | # override_doctype_dashboards = { 175 | # "Task": "frappe_graphql.task.get_dashboard_data" 176 | # } 177 | 178 | # exempt linked doctypes from being automatically cancelled 179 | # 180 | # auto_cancel_exempted_doctypes = ["Auto Repeat"] 181 | 182 | clear_cache = "frappe_graphql.cache.clear_cache" 183 | -------------------------------------------------------------------------------- /frappe_graphql/modules.txt: -------------------------------------------------------------------------------- 1 | Frappe Graphql -------------------------------------------------------------------------------- /frappe_graphql/patches.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_graphql/fbe04b7d55eb13f72d4b8ceb451335fd7eaac6a0/frappe_graphql/patches.txt -------------------------------------------------------------------------------- /frappe_graphql/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_graphql/fbe04b7d55eb13f72d4b8ceb451335fd7eaac6a0/frappe_graphql/templates/__init__.py -------------------------------------------------------------------------------- /frappe_graphql/templates/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_graphql/fbe04b7d55eb13f72d4b8ceb451335fd7eaac6a0/frappe_graphql/templates/pages/__init__.py -------------------------------------------------------------------------------- /frappe_graphql/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLResolveInfo 2 | 3 | 4 | def get_info_path_key(info: GraphQLResolveInfo): 5 | return "-".join([p for p in info.path.as_list() if isinstance(p, str)]) 6 | -------------------------------------------------------------------------------- /frappe_graphql/utils/cursor_pagination.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | import base64 3 | from typing import List 4 | from graphql import GraphQLResolveInfo, GraphQLError 5 | from frappe_graphql.utils.gql_fields import get_doctype_requested_fields 6 | 7 | 8 | class CursorPaginator(object): 9 | def __init__( 10 | self, 11 | doctype, 12 | filters=None, 13 | skip_process_filters=False, 14 | count_resolver=None, 15 | node_resolver=None, 16 | default_sorting_fields=None, 17 | default_sorting_direction=None, 18 | extra_args=None): 19 | 20 | if (not count_resolver) != (not node_resolver): 21 | frappe.throw( 22 | "Please provide both count_resolver & node_resolver to have custom implementation") 23 | 24 | self.doctype = doctype 25 | self.predefined_filters = filters 26 | self.skip_process_filters = skip_process_filters 27 | self.custom_count_resolver = count_resolver 28 | self.custom_node_resolver = node_resolver 29 | self.default_sorting_fields = default_sorting_fields 30 | self.default_sorting_direction = default_sorting_direction 31 | 32 | # Extra Args are helpful for custom resolvers 33 | self.extra_args = extra_args 34 | 35 | def resolve(self, obj, info: GraphQLResolveInfo, **kwargs): 36 | 37 | self.validate_connection_args(kwargs) 38 | 39 | self.resolve_obj = obj 40 | self.resolve_info = info 41 | self.resolve_kwargs = kwargs 42 | 43 | self.has_next_page = False 44 | self.has_previous_page = False 45 | self.before = kwargs.get("before") 46 | self.after = kwargs.get("after") 47 | self.first = kwargs.get("first") 48 | self.last = kwargs.get("last") 49 | 50 | self.filters = kwargs.get("filter") or [] 51 | self.filters.extend(self.predefined_filters or []) 52 | 53 | self.sorting_fields, self.sort_dir = self.get_sort_args(kwargs.get("sortBy")) 54 | 55 | self.original_sort_dir = self.sort_dir 56 | if self.last: 57 | # to get LAST, we swap the sort order 58 | # data will be reversed after fetch 59 | # We will flip hasNextPage & hasPreviousPage too 60 | self.sort_dir = "desc" if self.sort_dir == "asc" else "asc" 61 | 62 | self.cursor = self.after or self.before 63 | limit = (self.first or self.last) + 1 64 | requested_count = self.first or self.last 65 | 66 | if not self.skip_process_filters: 67 | self.filters = self.process_filters(self.filters) 68 | 69 | count = self.get_count(self.doctype, self.filters) 70 | 71 | if self.cursor: 72 | # Cursor filter should be applied after taking count 73 | self.has_previous_page = True 74 | self.filters.append(self.get_cursor_filter()) 75 | 76 | data = self.get_data(self.doctype, self.filters, self.sorting_fields, self.sort_dir, limit) 77 | matched_count = len(data) 78 | if matched_count > requested_count: 79 | self.has_next_page = True 80 | data.pop() 81 | 82 | # Flip! (last cursor is being used) 83 | if self.sort_dir != self.original_sort_dir: 84 | _swap_has_page = self.has_next_page 85 | self.has_next_page = self.has_previous_page 86 | self.has_previous_page = _swap_has_page 87 | data = reversed(data) 88 | 89 | edges = [frappe._dict( 90 | cursor=self.to_cursor(x, sorting_fields=self.sorting_fields), node=x 91 | ) for x in data] 92 | 93 | return frappe._dict( 94 | totalCount=count, 95 | pageInfo=frappe._dict( 96 | hasNextPage=self.has_next_page, 97 | hasPreviousPage=self.has_previous_page, 98 | startCursor=edges[0].cursor if len(edges) else None, 99 | endCursor=edges[-1].cursor if len(edges) else None 100 | ), 101 | edges=edges 102 | ) 103 | 104 | def validate_connection_args(self, args): 105 | first = args.get("first") 106 | last = args.get("last") 107 | 108 | if not first and not last: 109 | raise GraphQLError("Argument `first` or `last` should be specified") 110 | if first and not (isinstance(first, int) and first > 0): 111 | raise GraphQLError("Argument `first` must be a non-negative integer.") 112 | if last and not (isinstance(last, int) and last > 0): 113 | raise GraphQLError("Argument `last` must be a non-negative integer.") 114 | if first and last: 115 | raise GraphQLError("Argument `last` cannot be combined with `first`.") 116 | if first and args.get("before"): 117 | raise GraphQLError("Argument `first` cannot be combined with `before`.") 118 | if last and args.get("after"): 119 | raise GraphQLError("Argument `last` cannot be combined with `after`.") 120 | 121 | def get_count(self, doctype, filters): 122 | if self.custom_count_resolver: 123 | return self.custom_count_resolver( 124 | paginator=self, 125 | filters=filters 126 | ) 127 | 128 | return frappe.get_list( 129 | doctype, 130 | fields=["COUNT(*) as total_count"], 131 | filters=filters 132 | )[0].total_count 133 | 134 | def get_data(self, doctype, filters, sorting_fields, sort_dir, limit): 135 | if self.custom_node_resolver: 136 | return self.custom_node_resolver( 137 | paginator=self, 138 | filters=filters, 139 | sorting_fields=sorting_fields, 140 | sort_dir=sort_dir, 141 | limit=limit 142 | ) 143 | 144 | return frappe.get_list( 145 | doctype, 146 | fields=self.get_fields_to_fetch(doctype, filters, sorting_fields), 147 | filters=filters, 148 | order_by=f"{', '.join([f'{x} {sort_dir}' for x in sorting_fields])}", 149 | limit_page_length=limit 150 | ) 151 | 152 | def get_fields_to_fetch(self, doctype, filters, sorting_fields): 153 | return get_paginator_fields(doctype, self.resolve_info, sorting_fields) 154 | 155 | def get_sort_args(self, sorting_input=None): 156 | sort_dir = self.default_sorting_direction if self.default_sorting_direction in ( 157 | "asc", "desc") else "desc" 158 | if not self.default_sorting_fields: 159 | meta = frappe.get_meta(self.doctype) 160 | if meta.istable: 161 | sort_dir = "asc" 162 | sorting_fields = ["idx", "modified"] 163 | else: 164 | sorting_fields = ["modified"] 165 | else: 166 | sorting_fields = self.default_sorting_fields 167 | 168 | if sorting_input and sorting_input.get("field"): 169 | sort_dir = sorting_input.get("direction").lower() \ 170 | if sorting_input.get("direction") else "asc" 171 | 172 | sorting_input_field = sorting_input.get("field") 173 | if isinstance(sorting_input_field, str): 174 | sorting_fields = [sorting_input_field.lower()] 175 | elif isinstance(sorting_input_field, (list, tuple)): 176 | sorting_fields = sorting_input_field 177 | 178 | return sorting_fields, sort_dir 179 | 180 | def process_filters(self, input_filters): 181 | filters = [] 182 | operator_map = frappe._dict( 183 | EQ="=", NEQ="!=", LT="<", GT=">", LTE="<=", GTE=">=", 184 | LIKE="like", NOT_LIKE="not like" 185 | ) 186 | for f in input_filters: 187 | if not isinstance(f, dict): 188 | filters.append(f) 189 | else: 190 | filters.append([ 191 | f.get("fieldname"), 192 | operator_map[f.get("operator")], 193 | f.get("value") 194 | ]) 195 | 196 | return filters 197 | 198 | def get_cursor_filter(self): 199 | """ 200 | Inspired from 201 | - https://stackoverflow.com/a/38017813/2041598 202 | 203 | Examples: 204 | Cursor: {colA > A} 205 | -> (colA > A) 206 | 207 | Cursor: {colA > A, colB > B} 208 | -> (colA >= A AND (colA > A OR colB > B)) 209 | 210 | Cursor: {colA > A, colB > B, colC > C} 211 | -> (colA >= A AND (colA > A OR (colB >= B AND (colB > B OR colC > C)))) 212 | 213 | Cursor: {colA < A} 214 | -> (colA <= A OR colA IS NULL) 215 | 216 | Cursor: {colA < A, colB < B} 217 | -> (colA <= A OR colA IS NULL AND 218 | ((colA < A OR colA IS NULL) OR (colB < B OR colB IS NULL))) 219 | 220 | !! NONE Cursors !!: 221 | 222 | Cursor: {colA > None, colB > B} 223 | -> ((colA IS NULL && colB > B) OR colA IS NOT NULL) 224 | 225 | Cursor: {colA < None, colB < B} 226 | -> (colB IS NULL AND (colB < B OR colB IS NONE)) 227 | """ 228 | cursor_values = self.from_cursor(self.cursor) 229 | operator_map = { 230 | "after": {"asc": ">", "desc": "<"}, 231 | "before": {"asc": "<", "desc": ">"}, 232 | } 233 | operator = operator_map["after"][self.original_sort_dir] \ 234 | if self.after else operator_map["before"][self.original_sort_dir] 235 | 236 | def format_column_name(column): 237 | if "." in column: 238 | return column 239 | meta = frappe.get_meta(self.doctype) 240 | return f"`tab{self.doctype}`.{column}" if column in \ 241 | meta.get_valid_columns() else column 242 | 243 | def db_escape(v): 244 | return frappe.db.escape(v) 245 | 246 | def _get_cursor_column_condition(operator, column, value, include_equals=False): 247 | if operator == ">": 248 | return format_column_name(column) \ 249 | + f" {operator}{'=' if include_equals else ''} " \ 250 | + db_escape(value) 251 | else: 252 | if value is None: 253 | return format_column_name(column) \ 254 | + " IS NULL" 255 | return "(" \ 256 | + format_column_name(column) \ 257 | + f" {operator}{'=' if include_equals else ''} " \ 258 | + db_escape(value) \ 259 | + " OR " \ 260 | + format_column_name(column) \ 261 | + " IS NULL)" 262 | 263 | def _get_cursor_condition(sorting_fields, values): 264 | """ 265 | Returns 266 | sf[0]_cnd AND (sf[0]_cnd OR (sf[1:])) 267 | """ 268 | nonlocal operator 269 | 270 | if operator == ">" and values[0] is None: 271 | sub_condition = "" 272 | if len(sorting_fields) > 1: 273 | sub_condition = _get_cursor_condition( 274 | sorting_fields=sorting_fields[1:], values=values[1:]) 275 | 276 | if sub_condition: 277 | return f"(({format_column_name(sorting_fields[0])} IS NULL AND {sub_condition})" \ 278 | + f" OR {format_column_name(sorting_fields[0])} IS NOT NULL)" 279 | return "" 280 | 281 | condition = _get_cursor_column_condition( 282 | operator=operator, 283 | column=sorting_fields[0], 284 | value=values[0], 285 | include_equals=len(sorting_fields) > 1 286 | ) 287 | 288 | if len(sorting_fields) == 1: 289 | return condition 290 | 291 | next_condition = _get_cursor_condition( 292 | sorting_fields=sorting_fields[1:], values=values[1:]) 293 | 294 | if next_condition: 295 | if values[0] is not None: 296 | condition += " AND (" + _get_cursor_column_condition( 297 | operator=operator, 298 | column=sorting_fields[0], 299 | value=values[0] 300 | ) 301 | condition += f" OR {next_condition})" 302 | else: 303 | # If values[0] is none 304 | # sf[0] is NULL AND (sf[1:]) condition is used 305 | condition += f" AND ({next_condition})" 306 | 307 | return condition 308 | 309 | if len(self.sorting_fields) != len(cursor_values): 310 | frappe.throw("Invalid Cursor") 311 | 312 | return _get_cursor_condition(sorting_fields=self.sorting_fields, values=cursor_values) 313 | 314 | def to_cursor(self, row, sorting_fields): 315 | # sorting_fields could be [custom_table.field_1], 316 | # where only field_1 will be available on row 317 | _json = frappe.as_json([row.get(x.split('.')[1] if '.' in x else x) 318 | for x in sorting_fields]) 319 | return frappe.safe_decode(base64.b64encode(_json.encode("utf-8"))) 320 | 321 | def from_cursor(self, cursor): 322 | return frappe.parse_json(frappe.safe_decode(base64.b64decode(cursor))) 323 | 324 | 325 | def get_paginator_fields( 326 | doctype: str, 327 | info: GraphQLResolveInfo, 328 | extra_fields: List[str] = None, 329 | parent_doctype=None 330 | ): 331 | """ 332 | we know how the structure looks like based on the specs 333 | https://relay.dev/graphql/connections.htm 334 | so jmespath_str can be determined.. 335 | """ 336 | return get_doctype_requested_fields( 337 | doctype=doctype, 338 | info=info, 339 | mandatory_fields=set(extra_fields) if extra_fields else None, 340 | parent_doctype=parent_doctype, 341 | jmespath_str="edges.node" 342 | ) 343 | -------------------------------------------------------------------------------- /frappe_graphql/utils/depth_limit_validator.py: -------------------------------------------------------------------------------- 1 | from frappe import _ 2 | from graphql import (ValidationRule, ValidationContext, DefinitionNode, FragmentDefinitionNode, 3 | OperationDefinitionNode, 4 | Node, GraphQLError, FieldNode, InlineFragmentNode, FragmentSpreadNode) 5 | from typing import Optional, Union, Callable, Pattern, List, Dict 6 | 7 | from frappe_graphql.utils.introspection import is_introspection_key 8 | 9 | IgnoreType = Union[Callable[[str], bool], Pattern, str] 10 | 11 | """ 12 | Copied from 13 | https://github.com/graphql-python/graphene/blob/a61f0a214d4087acac097ab05f3969d77d0754b5/graphene/validation/depth_limit.py#L108 14 | """ 15 | 16 | 17 | def depth_limit_validator( 18 | max_depth: int, 19 | ignore: Optional[List[IgnoreType]] = None, 20 | callback: Callable[[Dict[str, int]], None] = None, 21 | ): 22 | class DepthLimitValidator(ValidationRule): 23 | def __init__(self, validation_context: ValidationContext): 24 | document = validation_context.document 25 | definitions = document.definitions 26 | 27 | fragments = get_fragments(definitions) 28 | queries = get_queries_and_mutations(definitions) 29 | query_depths = {} 30 | 31 | for name in queries: 32 | query_depths[name] = determine_depth( 33 | node=queries[name], 34 | fragments=fragments, 35 | depth_so_far=0, 36 | max_depth=max_depth, 37 | context=validation_context, 38 | operation_name=name, 39 | ignore=ignore, 40 | ) 41 | if callable(callback): 42 | callback(query_depths) 43 | super().__init__(validation_context) 44 | 45 | return DepthLimitValidator 46 | 47 | 48 | def get_fragments( 49 | definitions: List[DefinitionNode], 50 | ) -> Dict[str, FragmentDefinitionNode]: 51 | fragments = {} 52 | for definition in definitions: 53 | if isinstance(definition, FragmentDefinitionNode): 54 | fragments[definition.name.value] = definition 55 | return fragments 56 | 57 | 58 | # This will actually get both queries and mutations. 59 | # We can basically treat those the same 60 | def get_queries_and_mutations( 61 | definitions: List[DefinitionNode], 62 | ) -> Dict[str, OperationDefinitionNode]: 63 | operations = {} 64 | 65 | for definition in definitions: 66 | if isinstance(definition, OperationDefinitionNode): 67 | operation = definition.name.value if definition.name else "anonymous" 68 | operations[operation] = definition 69 | return operations 70 | 71 | 72 | def determine_depth( 73 | node: Node, 74 | fragments: Dict[str, FragmentDefinitionNode], 75 | depth_so_far: int, 76 | max_depth: int, 77 | context: ValidationContext, 78 | operation_name: str, 79 | ignore: Optional[List[IgnoreType]] = None, 80 | ) -> int: 81 | if depth_so_far > max_depth: 82 | context.report_error( 83 | GraphQLError( 84 | _("'{0}' exceeds maximum operation depth of {1}.").format(operation_name, 85 | max_depth), 86 | [node], 87 | ) 88 | ) 89 | return depth_so_far 90 | if isinstance(node, FieldNode): 91 | should_ignore = is_introspection_key(node.name.value) or is_ignored( 92 | node, ignore 93 | ) 94 | 95 | if should_ignore or not node.selection_set: 96 | return 0 97 | return 1 + max( 98 | map( 99 | lambda selection: determine_depth( 100 | node=selection, 101 | fragments=fragments, 102 | depth_so_far=depth_so_far + 1, 103 | max_depth=max_depth, 104 | context=context, 105 | operation_name=operation_name, 106 | ignore=ignore, 107 | ), 108 | node.selection_set.selections, 109 | ) 110 | ) 111 | elif isinstance(node, FragmentSpreadNode): 112 | return determine_depth( 113 | node=fragments[node.name.value], 114 | fragments=fragments, 115 | depth_so_far=depth_so_far, 116 | max_depth=max_depth, 117 | context=context, 118 | operation_name=operation_name, 119 | ignore=ignore, 120 | ) 121 | elif isinstance( 122 | node, (InlineFragmentNode, FragmentDefinitionNode, OperationDefinitionNode) 123 | ): 124 | return max( 125 | map( 126 | lambda selection: determine_depth( 127 | node=selection, 128 | fragments=fragments, 129 | depth_so_far=depth_so_far, 130 | max_depth=max_depth, 131 | context=context, 132 | operation_name=operation_name, 133 | ignore=ignore, 134 | ), 135 | node.selection_set.selections, 136 | ) 137 | ) 138 | else: 139 | raise Exception( 140 | _("Depth crawler cannot handle: {0}.").format(node.kind) 141 | ) 142 | 143 | 144 | def is_ignored(node: FieldNode, ignore: Optional[List[IgnoreType]] = None) -> bool: 145 | if ignore is None: 146 | return False 147 | for rule in ignore: 148 | field_name = node.name.value 149 | if isinstance(rule, str): 150 | if field_name == rule: 151 | return True 152 | elif isinstance(rule, Pattern): 153 | if rule.match(field_name): 154 | return True 155 | elif callable(rule): 156 | if rule(field_name): 157 | return True 158 | else: 159 | raise ValueError(_("Invalid ignore option: {0}.").format(rule)) 160 | return False 161 | -------------------------------------------------------------------------------- /frappe_graphql/utils/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from .error_coded_exceptions import * # noqa 2 | 3 | 4 | class GraphQLFileSyntaxError(Exception): 5 | def __init__(self, schema_file, message) -> None: 6 | super().__init__() 7 | self.message = self.format_message(schema_file, message) 8 | 9 | def format_message(self, schema_file, message): 10 | return f"Could not load {schema_file}:\n{message}" 11 | 12 | def __str__(self): 13 | return self.message 14 | -------------------------------------------------------------------------------- /frappe_graphql/utils/exceptions/error_coded_exceptions.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from typing import List 3 | 4 | 5 | class GQLExecutionUserError(Exception): 6 | error_code = "UNKNOWN_ERROR" 7 | message = "Unknown Error" 8 | additional_data = frappe._dict() 9 | 10 | def as_dict(self): 11 | return frappe._dict( 12 | error_code=self.error_code, 13 | message=self.message, 14 | **self.additional_data 15 | ) 16 | 17 | 18 | class GQLExecutionUserErrorMultiple(Exception): 19 | errors: List[GQLExecutionUserError] = [] 20 | 21 | def __init__(self, errors: List[GQLExecutionUserError] = []): 22 | self.errors = errors 23 | 24 | def as_dict_list(self): 25 | return [ 26 | x.as_dict() 27 | for x in self.errors 28 | ] 29 | 30 | 31 | def ERROR_CODED_EXCEPTIONS(error_key="errors"): 32 | def inner(func): 33 | def wrapper(*args, **kwargs): 34 | try: 35 | response = func(*args, **kwargs) 36 | response[error_key] = [] 37 | return response 38 | except GQLExecutionUserError as e: 39 | return frappe._dict({ 40 | error_key: [e.as_dict()] 41 | }) 42 | except GQLExecutionUserErrorMultiple as e: 43 | return frappe._dict({ 44 | error_key: e.as_dict_list() 45 | }) 46 | 47 | return wrapper 48 | 49 | return inner 50 | -------------------------------------------------------------------------------- /frappe_graphql/utils/execution/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_graphql/fbe04b7d55eb13f72d4b8ceb451335fd7eaac6a0/frappe_graphql/utils/execution/__init__.py -------------------------------------------------------------------------------- /frappe_graphql/utils/file.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.utils import cint 3 | from frappe.handler import ALLOWED_MIMETYPES 4 | 5 | 6 | def make_file_document( 7 | file_key, doctype=None, docname=None, fieldname=None, is_private=None, 8 | ignore_permissions=False): 9 | user = None 10 | if not ignore_permissions and frappe.session.user == 'Guest': 11 | if frappe.get_system_settings('allow_guests_to_upload_files'): 12 | ignore_permissions = True 13 | else: 14 | raise frappe.PermissionError("Guest uploads are not allowed") 15 | else: 16 | user = frappe.get_doc("User", frappe.session.user) 17 | 18 | files = frappe.request.files 19 | content = None 20 | filename = None 21 | 22 | if file_key in files: 23 | file = files[file_key] 24 | content = file.stream.read() 25 | filename = file.filename 26 | 27 | frappe.local.uploaded_file = content 28 | frappe.local.uploaded_filename = filename 29 | 30 | if frappe.session.user == 'Guest' or (user and not user.has_desk_access()): 31 | import mimetypes 32 | filetype = mimetypes.guess_type(filename)[0] 33 | if filetype not in ALLOWED_MIMETYPES: 34 | frappe.throw(frappe._("You can only upload JPG, PNG, PDF, or Microsoft documents.")) 35 | 36 | ret = frappe.get_doc({ 37 | "doctype": "File", 38 | "attached_to_doctype": doctype, 39 | "attached_to_name": docname, 40 | "attached_to_field": fieldname, 41 | "file_name": filename, 42 | "is_private": cint(is_private), 43 | "content": content 44 | }) 45 | ret.save(ignore_permissions=ignore_permissions) 46 | return ret 47 | -------------------------------------------------------------------------------- /frappe_graphql/utils/generate_sdl/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import frappe 3 | 4 | from .doctype import get_doctype_sdl 5 | 6 | IGNORED_DOCTYPES = [ 7 | "Installed Application", 8 | "Installed Applications", 9 | "Content Activity", # broken EPRNext DocType 10 | ] 11 | 12 | SDL_PREDEFINED_DOCTYPES = [ 13 | # uploadFile 14 | "File", 15 | 16 | # Owner, Modified By 17 | "User", 18 | 19 | # User 20 | "Gender", "Has Role", "Role Profile", "Role", "Language", 21 | 22 | # File.attached_to_doctype 23 | "DocType", "Module Def", "DocField", "DocPerm", 24 | 25 | # Other 26 | "DocType Action", 27 | "DocType Link", 28 | "Domain", 29 | "Dynamic Link", 30 | ] 31 | 32 | GQL_RESERVED_TERMS = [ 33 | "Query", 34 | "Mutation", 35 | "Subscription", 36 | "Int", 37 | "Float", 38 | "Boolean", 39 | "ID", 40 | "String", 41 | ] 42 | 43 | 44 | def make_doctype_sdl_files( 45 | target_dir, 46 | app=None, 47 | modules=None, 48 | doctypes=None, 49 | ignore_custom_fields=False, 50 | disable_enum_select_fields=False 51 | ): 52 | 53 | if not modules: 54 | modules = [] 55 | 56 | if not doctypes: 57 | doctypes = [] 58 | 59 | specific_doctypes = doctypes or [] 60 | doctypes = get_doctypes( 61 | app=app, 62 | modules=modules, 63 | doctypes=doctypes 64 | ) 65 | 66 | options = frappe._dict( 67 | disable_enum_select_fields=disable_enum_select_fields, 68 | ignore_custom_fields=ignore_custom_fields 69 | ) 70 | 71 | if not os.path.exists(target_dir): 72 | os.makedirs(target_dir) 73 | 74 | def write_file(filename, contents): 75 | target_file = os.path.join( 76 | target_dir, f"{frappe.scrub(filename)}.graphql") 77 | with open(target_file, "w") as f: 78 | f.write(contents) 79 | 80 | for doctype in doctypes: 81 | 82 | # Warn if there is an "s" form plural of a doctype 83 | if doctype[:-2:-1] == "s": 84 | if doctype[:-1:1] in doctypes and doctype not in IGNORED_DOCTYPES: 85 | IGNORED_DOCTYPES.append(doctype) 86 | 87 | print("WARN: sdl generation of DocTypes that are named with the 's' form " + 88 | "plural of another DocType is not supported. " + 89 | f"Skipping sdl generation for \"{doctype}\"") 90 | 91 | # Warn if a DocType has a reserved name 92 | if doctype in GQL_RESERVED_TERMS: 93 | print("WARN: sdl generation of DocTypes that share names with the following " + 94 | f"GQL Reserved terms is not supported: {GQL_RESERVED_TERMS}. " + 95 | f"Skipping sdl generation for \"{doctype}\"") 96 | 97 | # Warn if a Doctype has an 'invalid' name 98 | if "-" in doctype: 99 | print("WARN: The following DocType has an invalid character '-' in its name " + 100 | f"and will not be resolved automatically: {doctype}. " + 101 | "A custom resolver will have to be implemented.") 102 | 103 | if doctype not in specific_doctypes and ( 104 | doctype in IGNORED_DOCTYPES or 105 | doctype in SDL_PREDEFINED_DOCTYPES or 106 | doctype in GQL_RESERVED_TERMS 107 | ): 108 | continue 109 | sdl = get_doctype_sdl(doctype=doctype, options=options) 110 | write_file(doctype, sdl) 111 | 112 | 113 | def get_doctypes(app=None, modules=None, doctypes=None): 114 | 115 | if not doctypes: 116 | doctypes = [] 117 | 118 | modules = list(modules or []) 119 | doctypes = list(doctypes or []) 120 | if app: 121 | if app not in frappe.get_installed_apps(): 122 | raise Exception("App {} is not installed in this site".format(app)) 123 | 124 | modules.extend([x.name for x in frappe.get_all( 125 | "Module Def", 126 | {"app_name": app} 127 | )]) 128 | 129 | if modules: 130 | for module in modules: 131 | if not frappe.db.exists("Module Def", module): 132 | raise Exception("Invalid Module: " + module) 133 | 134 | doctypes.extend([x.name for x in frappe.get_all( 135 | "DocType", 136 | {"module": ["IN", modules]} 137 | )]) 138 | 139 | if doctypes: 140 | for dt in doctypes: 141 | if not frappe.db.exists("DocType", dt): 142 | raise Exception("Invalid DocType: " + dt) 143 | else: 144 | doctypes = [x.name for x in frappe.get_all("DocType")] 145 | 146 | return doctypes 147 | -------------------------------------------------------------------------------- /frappe_graphql/utils/generate_sdl/doctype.py: -------------------------------------------------------------------------------- 1 | import re 2 | import inflect 3 | 4 | import frappe 5 | from frappe.utils import cint 6 | from frappe.model import default_fields, display_fieldtypes, table_fields 7 | from frappe.model.meta import Meta 8 | 9 | 10 | def get_doctype_sdl(doctype, options): 11 | """ 12 | options = dict( 13 | disable_enum_select_fields=False, 14 | ignore_custom_fields=False 15 | ) 16 | """ 17 | generated_enums = frappe._dict() 18 | 19 | meta = frappe.get_meta(doctype) 20 | sdl, defined_fieldnames = get_basic_doctype_sdl(meta, options=options, generated_enums=generated_enums) 21 | 22 | # Extend Doctype with Custom Fields 23 | if not options.ignore_custom_fields and len(meta.get_custom_fields()): 24 | sdl += get_custom_field_sdl(meta, defined_fieldnames, options=options) 25 | 26 | if not options.disable_enum_select_fields: 27 | sdl += get_select_docfield_enums(meta=meta, options=options, generated_enums=generated_enums) 28 | 29 | if not meta.istable: 30 | 31 | # DocTypeSortingInput 32 | if not meta.issingle: 33 | sdl += get_sorting_input(meta) 34 | sdl += get_connection_type(meta) 35 | 36 | # Extend QueryType 37 | sdl += get_query_type_extension(meta) 38 | 39 | return sdl 40 | 41 | 42 | def get_basic_doctype_sdl(meta: Meta, options: dict, generated_enums=None): 43 | dt = format_doctype(meta.name) 44 | sdl = f"type {dt} implements BaseDocType {{" 45 | 46 | defined_fieldnames = [] + list(default_fields) 47 | 48 | for field in default_fields: 49 | if field in ("idx", "docstatus"): 50 | fieldtype = "Int" 51 | elif field in ("owner", "modified_by"): 52 | fieldtype = "User!" 53 | elif field == "parent": 54 | fieldtype = "BaseDocType" 55 | else: 56 | fieldtype = "String" 57 | sdl += f"\n {field}: {fieldtype}" 58 | sdl += "\n owner__name: String!" 59 | sdl += "\n modified_by__name: String!" 60 | sdl += "\n parent__name: String" 61 | 62 | for field in meta.fields: 63 | if field.fieldtype in display_fieldtypes: 64 | continue 65 | if field.fieldname in defined_fieldnames: 66 | continue 67 | if cint(field.get("is_custom_field")): 68 | continue 69 | defined_fieldnames.append(field.fieldname) 70 | sdl += f"\n {get_field_sdl(meta, field, options=options, generated_enums=generated_enums)}" 71 | if field.fieldtype in ("Link", "Dynamic Link"): 72 | sdl += f"\n {get_link_field_name_sdl(field)}" 73 | 74 | sdl += "\n}" 75 | 76 | return sdl, defined_fieldnames 77 | 78 | 79 | def get_custom_field_sdl(meta, defined_fieldnames, options): 80 | sdl = f"\n\nextend type {format_doctype(meta.name)} {{" 81 | for field in meta.get_custom_fields(): 82 | if field.fieldtype in display_fieldtypes: 83 | continue 84 | if field.fieldname in defined_fieldnames: 85 | continue 86 | defined_fieldnames.append(field.fieldname) 87 | sdl += f"\n {get_field_sdl(meta, field, options=options)}" 88 | if field.fieldtype in ("Link", "Dynamic Link"): 89 | sdl += f"\n {get_link_field_name_sdl(field)}" 90 | sdl += "\n}" 91 | 92 | return sdl 93 | 94 | 95 | def get_select_docfield_enums(meta, options, generated_enums=None): 96 | sdl = "" 97 | for field in meta.get("fields", {"fieldtype": "Select"}): 98 | 99 | has_no_options = all([len(x or "") == 0 for x in (field.options or "").split("\n")]) 100 | 101 | has_invalid_options = False 102 | if any([ 103 | contains_reserved_characters(option) 104 | for option in (field.options or "").split("\n") 105 | ]): 106 | has_invalid_options = True 107 | 108 | if (options.ignore_custom_fields and cint(field.get("is_custom_field"))) \ 109 | or has_no_options \ 110 | or has_invalid_options: 111 | continue 112 | 113 | sdl += "\n\n" 114 | sdl += f"enum {get_select_docfield_enum_name(meta.name, field, generated_enums)} {{" 115 | for option in (field.get("options") or "").split("\n"): 116 | if not option or not len(option): 117 | continue 118 | sdl += f"\n {frappe.scrub(option).upper()}" 119 | 120 | sdl += "\n}" 121 | 122 | return sdl 123 | 124 | 125 | def get_sorting_input(meta): 126 | dt = format_doctype(meta.name) 127 | 128 | sdl = f"\n\nenum {dt}SortField {{" 129 | sdl += "\n NAME" 130 | sdl += "\n CREATION" 131 | sdl += "\n MODIFIED" 132 | for field in meta.fields: 133 | if not field.search_index and not field.unique: 134 | continue 135 | sdl += f"\n {field.fieldname.upper()}" 136 | sdl += "\n}" 137 | 138 | sdl += f"\n\ninput {dt}SortingInput {{" 139 | sdl += "\n direction: SortDirection!" 140 | sdl += f"\n field: {dt}SortField!" 141 | sdl += "\n}" 142 | return sdl 143 | 144 | 145 | def get_connection_type(meta): 146 | dt = format_doctype(meta.name) 147 | sdl = f"\n\ntype {dt}CountableEdge {{" 148 | sdl += "\n cursor: String!" 149 | sdl += f"\n node: {dt}!" 150 | sdl += "\n}" 151 | 152 | sdl += f"\n\ntype {dt}CountableConnection {{" 153 | sdl += "\n pageInfo: PageInfo!" 154 | sdl += "\n totalCount: Int" 155 | sdl += f"\n edges: [{dt}CountableEdge!]!" 156 | sdl += "\n}" 157 | 158 | return sdl 159 | 160 | 161 | def get_query_type_extension(meta: Meta): 162 | dt = format_doctype(meta.name) 163 | sdl = "\n\nextend type Query {" 164 | if meta.issingle: 165 | sdl += f"\n {dt}: {dt}!" 166 | else: 167 | plural = get_plural(meta.name) 168 | if plural == meta.name: 169 | prefix = "A" 170 | if dt[0].lower() in ("a", "e", "i", "o", "u"): 171 | prefix = "An" 172 | 173 | sdl += f"\n {prefix}{dt}(name: String!): {dt}!" 174 | else: 175 | sdl += f"\n {dt}(name: String!): {dt}!" 176 | 177 | plural_dt = format_doctype(plural) 178 | sdl += f"\n {plural_dt}(filter: [DBFilterInput], sortBy: {dt}SortingInput, " 179 | sdl += "before: String, after: String, " 180 | sdl += f"first: Int, last: Int): {dt}CountableConnection!" 181 | 182 | sdl += "\n}\n" 183 | return sdl 184 | 185 | 186 | def get_field_sdl(meta, docfield, options: dict, generated_enums: list = None): 187 | return f"{docfield.fieldname}: {get_graphql_type(meta, docfield, options=options, generated_enums=generated_enums)}" 188 | 189 | 190 | def get_link_field_name_sdl(docfield): 191 | return f"{docfield.fieldname}__name: String" 192 | 193 | 194 | def get_graphql_type(meta, docfield, options: dict, generated_enums=None): 195 | string_fieldtypes = [ 196 | "Small Text", "Long Text", "Code", "Text Editor", "Markdown Editor", "HTML Editor", 197 | "Date", "Datetime", "Time", "Text", "Data", "Rating", "Read Only", 198 | "Attach", "Attach Image", "Signature", "Color", "Barcode", "Geolocation", "Duration" 199 | ] 200 | int_fieldtypes = ["Int", "Long Int", "Check"] 201 | float_fieldtypes = ["Currency", "Float", "Percent"] 202 | 203 | if options.disable_enum_select_fields: 204 | string_fieldtypes.append("Select") 205 | 206 | graphql_type = None 207 | if docfield.fieldtype in string_fieldtypes: 208 | graphql_type = "String" 209 | elif docfield.fieldtype in int_fieldtypes: 210 | graphql_type = "Int" 211 | elif docfield.fieldtype in float_fieldtypes: 212 | graphql_type = "Float" 213 | elif docfield.fieldtype == "Link": 214 | graphql_type = f"{format_doctype(docfield.options)}" 215 | elif docfield.fieldtype == "Dynamic Link": 216 | graphql_type = "BaseDocType" 217 | elif docfield.fieldtype in table_fields: 218 | graphql_type = f"[{format_doctype(docfield.options)}!]!" 219 | elif docfield.fieldtype == "Password": 220 | graphql_type = "Password" 221 | elif docfield.fieldtype == "Select": 222 | graphql_type = get_select_docfield_enum_name(meta.name, docfield, generated_enums) 223 | 224 | # Mark NonNull if there is no empty option and is required 225 | has_empty_option = all([len(x or "") == 0 for x in (docfield.options or "").split("\n")]) 226 | 227 | has_invalid_options = False 228 | if any([ 229 | contains_reserved_characters(option) 230 | for option in (docfield.options or "").split("\n") 231 | ]): 232 | has_invalid_options = True 233 | 234 | if has_empty_option or has_invalid_options: 235 | graphql_type = "String" 236 | if docfield.reqd: 237 | graphql_type += "!" 238 | else: 239 | frappe.throw(f"Invalid fieldtype: {docfield.fieldtype}") 240 | 241 | if docfield.reqd and graphql_type[-1] != "!": 242 | graphql_type += "!" 243 | 244 | return graphql_type 245 | 246 | 247 | def get_plural(doctype): 248 | p = inflect.engine() 249 | return p.plural(doctype) 250 | 251 | 252 | def format_doctype(doctype): 253 | return remove_reserved_characters(doctype.replace(" ", "").replace("-", "_")) 254 | 255 | 256 | def get_select_docfield_enum_name(doctype, docfield, generated_enums=None): 257 | 258 | name = remove_reserved_characters( 259 | f"{doctype}{(docfield.label or docfield.fieldname).title()}SelectOptions" 260 | .replace(" ", "")) 261 | 262 | if hasattr(generated_enums,'values'): 263 | if name in generated_enums.values(): 264 | name = remove_reserved_characters( 265 | f"{doctype}{(docfield.fieldname).title()}SelectOptions" 266 | .replace(" ", "")) 267 | 268 | if generated_enums is not None: 269 | if docfield in generated_enums: 270 | name = generated_enums[docfield] 271 | else: 272 | generated_enums[docfield] = name 273 | 274 | return name 275 | 276 | 277 | def remove_reserved_characters(string): 278 | return re.sub(r"[^A-Za-z0-9_ ]", "", string) 279 | 280 | 281 | def contains_reserved_characters(string): 282 | if not string: 283 | return False 284 | 285 | matches = re.match(r"^[A-Za-z_ ][A-Za-z0-9_ ]*$", string) 286 | if matches: 287 | return False 288 | else: 289 | return True 290 | -------------------------------------------------------------------------------- /frappe_graphql/utils/gql_fields.py: -------------------------------------------------------------------------------- 1 | import jmespath 2 | from graphql import GraphQLResolveInfo 3 | 4 | from mergedeep import merge, Strategy 5 | 6 | from frappe_graphql.utils.introspection import is_introspection_key 7 | from frappe_graphql.utils import get_info_path_key 8 | from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype 9 | 10 | 11 | def collect_fields(node: dict, fragments: dict): 12 | """ 13 | Recursively collects fields from the AST 14 | Inspired from https://gist.github.com/mixxorz/dc36e180d1888629cf33 15 | 16 | Notes: 17 | => Please make sure your node and fragments passed have been converted to dicts 18 | => Best used in conjunction with `get_allowed_fieldnames_for_doctype()` 19 | 20 | Args: 21 | node (dict): A node in the AST 22 | fragments (dict): Fragment definitions 23 | Returns: 24 | A dict mapping each field found, along with their sub fields. 25 | {'name': {}, 26 | 'sentimentsPerLanguage': {'id': {}, 27 | 'name': {}, 28 | 'totalSentiments': {}}, 29 | 'slug': {}} 30 | """ 31 | 32 | field = {} 33 | 34 | if node.get('selection_set'): 35 | for leaf in node['selection_set']['selections']: 36 | if leaf['kind'] == 'field': 37 | field[leaf['name']['value']] = collect_fields(leaf, fragments) 38 | elif leaf['kind'] == 'fragment_spread': 39 | field.update(collect_fields(fragments[leaf['name']['value']], 40 | fragments)) 41 | return field 42 | 43 | 44 | def get_field_tree_dict(info: GraphQLResolveInfo): 45 | """ 46 | A hierarchical dictionary of the graphql resolver fields nodes merged and returned. 47 | Args: 48 | info (GraphQLResolveInfo): GraphqlResolver Info 49 | Returns: 50 | A dict mapping each field found, along with their sub fields. 51 | {'name': {}, 52 | 'sentimentsPerLanguage': {'id': {}, 53 | 'name': {}, 54 | 'totalSentiments': {}}, 55 | 'slug': {}} 56 | """ 57 | fragments = {name: value.to_dict() for name, value in info.fragments.items()} 58 | fields = {} 59 | for field_node in info.field_nodes: 60 | merge(fields, collect_fields(field_node.to_dict(), fragments), strategy=Strategy.ADDITIVE) 61 | return fields 62 | 63 | 64 | def get_doctype_requested_fields( 65 | doctype: str, 66 | info: GraphQLResolveInfo, 67 | mandatory_fields: set = None, 68 | parent_doctype: str = None, 69 | jmespath_str: str = None 70 | ): 71 | """ 72 | Returns the list of requested fields for the given doctype from a GraphQL query. 73 | 74 | :param doctype: The doctype to retrieve requested fields for. 75 | :type doctype: str 76 | 77 | :param info: The GraphQLResolveInfo object representing information about a 78 | resolver's execution. 79 | :type info: GraphQLResolveInfo 80 | 81 | :param mandatory_fields: A set of fields that should always be included in the returned list, 82 | even if not requested. 83 | :type mandatory_fields: set 84 | 85 | :param parent_doctype: The doctype of the parent object, if any. 86 | :type parent_doctype: str 87 | 88 | :param jmespath_str: The jmespath string leading to the field_node of the specified doctype. 89 | :type jmespath_str: str 90 | 91 | :return: The list of requested fields for the given doctype. 92 | :rtype: list of str 93 | """ 94 | p_key = get_info_path_key(info) 95 | if jmespath_str: 96 | p_key += f"-{jmespath_str}" 97 | requested_fields = info.context.get(p_key) 98 | 99 | if requested_fields is not None: 100 | return requested_fields 101 | 102 | field_tree = get_field_tree_dict(info) 103 | if jmespath_str: 104 | expression = jmespath.compile(jmespath_str) 105 | field_tree = expression.search(field_tree) 106 | 107 | selected_fields = { 108 | key.replace('__name', '') 109 | for key in (field_tree or {}).keys() 110 | if not is_introspection_key(key) 111 | } 112 | 113 | fieldnames = set(get_allowed_fieldnames_for_doctype( 114 | doctype=doctype, 115 | parent_doctype=parent_doctype 116 | )) 117 | 118 | requested_fields = selected_fields.intersection(fieldnames) 119 | if mandatory_fields: 120 | requested_fields.update(mandatory_fields) 121 | 122 | # send name always.. 123 | requested_fields.add("name") 124 | 125 | # cache it in context.. 126 | info.context[p_key] = requested_fields 127 | 128 | return list(requested_fields) 129 | -------------------------------------------------------------------------------- /frappe_graphql/utils/http.py: -------------------------------------------------------------------------------- 1 | from graphql import parse 2 | 3 | import frappe 4 | 5 | 6 | def get_masked_variables(query, variables): 7 | """ 8 | Return the variables dict with password field set to "******" 9 | """ 10 | if isinstance(variables, str): 11 | variables = frappe.parse_json(variables) 12 | 13 | variables = frappe._dict(variables) 14 | try: 15 | document = parse(query) 16 | for operation_definition in (getattr(document, "definitions", None) or []): 17 | for variable in (getattr(operation_definition, "variable_definitions", None) or []): 18 | variable_name = variable.variable.name.value 19 | variable_type = variable.type 20 | 21 | if variable_name not in variables: 22 | continue 23 | 24 | if not isinstance(variables[variable_name], str): 25 | continue 26 | 27 | # Password! NonNull 28 | if variable_type.kind == "non_null_type": 29 | variable_type = variable_type.type 30 | 31 | # Password is a named_type 32 | if variable_type.kind != "named_type" or not variable_type.name: 33 | continue 34 | 35 | if variable_type.name.value != "Password": 36 | continue 37 | 38 | variables[variable_name] = "*" * len(variables[variable_name]) 39 | except BaseException: 40 | return frappe._dict( 41 | status="Error Processing Variable Definitions", 42 | traceback=frappe.get_traceback() 43 | ) 44 | 45 | return variables 46 | 47 | 48 | def get_operation_name(query, operation_name): 49 | """ 50 | Gets the active operation name 51 | if operation_name is not specified in the request, 52 | it will take on the first operation definition if available. 53 | Otherwise returns 'unnamed-query' 54 | """ 55 | defined_operations = [] 56 | try: 57 | document = parse(query) 58 | for operation_definition in document.definitions: 59 | if operation_definition.kind != "operation_definition": 60 | continue 61 | 62 | # For non-named Queries 63 | if not operation_definition.name: 64 | continue 65 | 66 | defined_operations.append(operation_definition.name.value) 67 | except BaseException: 68 | pass 69 | 70 | if not operation_name and len(defined_operations): 71 | return defined_operations[0] 72 | elif operation_name in defined_operations: 73 | return operation_name 74 | elif operation_name: 75 | return f"{operation_name} (invalid)" 76 | else: 77 | return "unnamed-query" 78 | -------------------------------------------------------------------------------- /frappe_graphql/utils/introspection.py: -------------------------------------------------------------------------------- 1 | def is_introspection_key(key): 2 | # from: https://spec.graphql.org/June2018/#sec-Schema 3 | # > All types and directives defined within a schema must not have a name which 4 | # > begins with "__" (two underscores), as this is used exclusively 5 | # > by GraphQL’s introspection system. 6 | return str(key).startswith("__") 7 | -------------------------------------------------------------------------------- /frappe_graphql/utils/loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import frappe 3 | from typing import Generator 4 | 5 | import graphql 6 | from graphql import parse 7 | from graphql.error import GraphQLSyntaxError 8 | 9 | from .resolver import setup_default_resolvers 10 | from .exceptions import GraphQLFileSyntaxError 11 | 12 | graphql_schemas = {} 13 | 14 | 15 | def get_schema(): 16 | global graphql_schemas 17 | 18 | if frappe.local.site in graphql_schemas: 19 | return graphql_schemas.get(frappe.local.site) 20 | 21 | schema = graphql.build_schema(get_typedefs()) 22 | setup_default_resolvers(schema=schema) 23 | execute_schema_processors(schema=schema) 24 | 25 | graphql_schemas[frappe.local.site] = schema 26 | return schema 27 | 28 | 29 | def get_typedefs(): 30 | target_dir = frappe.get_site_path("doctype_sdls") 31 | schema = load_schema_from_path(target_dir) if os.path.isdir( 32 | target_dir) else "" 33 | 34 | for dir in frappe.get_hooks("graphql_sdl_dir"): 35 | dir = os.path.abspath(frappe.get_app_path("frappe", "../..", dir)) 36 | 37 | schema += f"\n\n\n# {dir}\n\n" 38 | schema += load_schema_from_path(dir) 39 | 40 | return schema 41 | 42 | 43 | def execute_schema_processors(schema): 44 | for cmd in frappe.get_hooks("graphql_schema_processors"): 45 | frappe.get_attr(cmd)(schema=schema) 46 | 47 | 48 | def load_schema_from_path(path: str) -> str: 49 | if os.path.isdir(path): 50 | schema_list = [read_graphql_file(f) for f in 51 | sorted(walk_graphql_files(path))] 52 | return "\n".join(schema_list) 53 | return read_graphql_file(os.path.abspath(path)) 54 | 55 | 56 | def walk_graphql_files(path: str) -> Generator[str, None, None]: 57 | extension = ".graphql" 58 | for dirpath, _, files in os.walk(path): 59 | for name in files: 60 | if extension and name.lower().endswith(extension): 61 | yield os.path.join(dirpath, name) 62 | 63 | 64 | def read_graphql_file(path: str) -> str: 65 | with open(path, "r") as graphql_file: 66 | schema = graphql_file.read() 67 | try: 68 | parse(schema) 69 | except GraphQLSyntaxError as e: 70 | raise GraphQLFileSyntaxError(path, str(e)) from e 71 | return schema 72 | -------------------------------------------------------------------------------- /frappe_graphql/utils/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .disable_introspection_queries import disable_introspection_queries # noqa -------------------------------------------------------------------------------- /frappe_graphql/utils/middlewares/disable_introspection_queries.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.utils import cint 3 | from graphql import GraphQLResolveInfo 4 | 5 | 6 | class IntrospectionDisabled(Exception): 7 | pass 8 | 9 | 10 | def disable_introspection_queries(next_resolver, obj, info: GraphQLResolveInfo, **kwargs): 11 | # https://github.com/jstacoder/graphene-disable-introspection-middleware 12 | if is_introspection_disabled() and info.field_name.lower() in ['__schema', '__introspection']: 13 | raise IntrospectionDisabled(frappe._("Introspection is disabled")) 14 | 15 | return next_resolver(obj, info, **kwargs) 16 | 17 | 18 | def is_introspection_disabled(): 19 | return not cint(frappe.local.conf.get("developer_mode")) and \ 20 | not cint(frappe.local.conf.get("enable_introspection_in_production")) 21 | -------------------------------------------------------------------------------- /frappe_graphql/utils/permissions.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import frappe 3 | from frappe.model import default_fields, no_value_fields 4 | from frappe.model.meta import Meta 5 | 6 | 7 | def get_allowed_fieldnames_for_doctype(doctype: str, parent_doctype: str = None): 8 | """ 9 | Gets a list of fieldnames that's allowed for the current User to 10 | read on the specified doctype. This includes default_fields 11 | """ 12 | _from_locals = _get_allowed_fieldnames_from_locals(doctype, parent_doctype) 13 | if _from_locals is not None: 14 | return _from_locals 15 | 16 | fieldnames = list(default_fields) 17 | fieldnames.remove("doctype") 18 | 19 | meta = frappe.get_meta(doctype) 20 | has_access_to = _get_permlevel_read_access(meta=frappe.get_meta(parent_doctype or doctype)) 21 | if not has_access_to: 22 | return [] 23 | 24 | for df in meta.fields: 25 | if df.fieldtype in no_value_fields: 26 | continue 27 | 28 | if df.permlevel is not None and df.permlevel not in has_access_to: 29 | continue 30 | 31 | fieldnames.append(df.fieldname) 32 | 33 | _set_allowed_fieldnames_to_locals( 34 | allowed_fields=fieldnames, 35 | doctype=doctype, 36 | parent_doctype=parent_doctype 37 | ) 38 | 39 | return fieldnames 40 | 41 | 42 | def is_field_permlevel_restricted_for_doctype( 43 | fieldname: str, doctype: str, parent_doctype: str = None): 44 | """ 45 | Returns a boolean when the given field is restricted for the current User under permlevel 46 | """ 47 | meta = frappe.get_meta(doctype) 48 | if meta.get_field(fieldname) is None: 49 | return False 50 | 51 | allowed_fieldnames = get_allowed_fieldnames_for_doctype( 52 | doctype=doctype, parent_doctype=parent_doctype) 53 | if fieldname not in allowed_fieldnames: 54 | return True 55 | 56 | return False 57 | 58 | 59 | def _get_permlevel_read_access(meta: Meta): 60 | if meta.istable: 61 | return [0] 62 | 63 | ptype = "read" 64 | _has_access_to = [] 65 | roles = frappe.get_roles() 66 | for perm in meta.permissions: 67 | if perm.get("role") not in roles or not perm.get(ptype): 68 | continue 69 | 70 | if perm.get("permlevel") in _has_access_to: 71 | continue 72 | 73 | _has_access_to.append(perm.get("permlevel")) 74 | 75 | return _has_access_to 76 | 77 | 78 | def _get_allowed_fieldnames_from_locals(doctype: str, parent_doctype: str = None): 79 | 80 | if not hasattr(frappe.local, "permlevel_fields"): 81 | frappe.local.permlevel_fields = dict() 82 | 83 | k = doctype 84 | if parent_doctype: 85 | k = (doctype, parent_doctype) 86 | 87 | return frappe.local.permlevel_fields.get(k) 88 | 89 | 90 | def _set_allowed_fieldnames_to_locals( 91 | allowed_fields: List[str], 92 | doctype: str, 93 | parent_doctype: str = None): 94 | 95 | if not hasattr(frappe.local, "permlevel_fields"): 96 | frappe.local.permlevel_fields = dict() 97 | 98 | k = doctype 99 | if parent_doctype: 100 | k = (doctype, parent_doctype) 101 | 102 | frappe.local.permlevel_fields[k] = allowed_fields 103 | -------------------------------------------------------------------------------- /frappe_graphql/utils/pre_load_schemas.py: -------------------------------------------------------------------------------- 1 | def pre_load_schemas(): 2 | """ 3 | Can be called in https://docs.gunicorn.org/en/stable/settings.html#pre-fork 4 | to pre-load the all sites schema's on all workers. 5 | """ 6 | from frappe.utils import get_sites 7 | from frappe import init_site, init, connect, get_installed_apps, destroy 8 | with init_site(): 9 | sites = get_sites() 10 | 11 | for site in sites: 12 | import frappe 13 | frappe.local.initialised = False 14 | init(site=site) 15 | connect(site) 16 | if "frappe_graphql" not in get_installed_apps(): 17 | continue 18 | try: 19 | from frappe_graphql import get_schema 20 | get_schema() 21 | except Exception: 22 | print(f"Failed to build schema for site {site}") 23 | finally: 24 | destroy() 25 | -------------------------------------------------------------------------------- /frappe_graphql/utils/pyutils.py: -------------------------------------------------------------------------------- 1 | from graphql.pyutils import FrozenDict, FrozenList 2 | 3 | 4 | def unfreeze(obj, ignore_types=None): 5 | """ 6 | FrozenDicts and FrozenLists come up in graphql generated ast 7 | They raise errors while pickling. 8 | 9 | Recursive Approach was not taken since it is very easy to exceed the recursion limit 10 | """ 11 | 12 | if not ignore_types: 13 | ignore_types = [] 14 | 15 | if obj is None: 16 | return obj 17 | 18 | to_process = [obj] 19 | while len(to_process) > 0: 20 | _obj = to_process.pop() 21 | 22 | for attr in dir(_obj): 23 | if attr.startswith("__"): 24 | continue 25 | value = getattr(_obj, attr) 26 | if isinstance(value, FrozenDict): 27 | value = {k: v for k, v in value.items()} 28 | to_process.extend(value.values()) 29 | elif isinstance(value, FrozenList): 30 | value = [x for x in value] 31 | to_process.extend(value) 32 | elif not callable(value) and not isinstance(value, tuple(ignore_types)): 33 | to_process.append(value) 34 | 35 | try: 36 | setattr(_obj, attr, value) 37 | except BaseException: 38 | pass 39 | 40 | return obj 41 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/__init__.py: -------------------------------------------------------------------------------- 1 | from graphql import ( 2 | GraphQLSchema, GraphQLType, GraphQLResolveInfo, 3 | GraphQLNonNull, GraphQLObjectType 4 | ) 5 | 6 | import frappe 7 | from frappe.model.meta import Meta 8 | 9 | from .root_query import setup_root_query_resolvers 10 | from .link_field import setup_link_field_resolvers 11 | from .select_fields import setup_select_field_resolvers 12 | from .child_tables import setup_child_table_resolvers 13 | from .translate import setup_translatable_resolvers 14 | from .utils import get_singular_doctype 15 | 16 | 17 | def setup_default_resolvers(schema: GraphQLSchema): 18 | setup_root_query_resolvers(schema=schema) 19 | 20 | doctype_resolver_processors = frappe.get_hooks("doctype_resolver_processors") 21 | 22 | # Setup custom resolvers for DocTypes 23 | for type_name, gql_type in schema.type_map.items(): 24 | dt = get_singular_doctype(type_name) 25 | if not dt or not isinstance(gql_type, GraphQLObjectType): 26 | continue 27 | 28 | meta = frappe.get_meta(dt) 29 | 30 | setup_frappe_df(meta, gql_type) 31 | setup_doctype_resolver(meta, gql_type) 32 | setup_link_field_resolvers(meta, gql_type) 33 | setup_select_field_resolvers(meta, gql_type) 34 | setup_child_table_resolvers(meta, gql_type) 35 | setup_translatable_resolvers(meta, gql_type) 36 | 37 | # Wrap all the resolvers set above with a mandatory-checker 38 | setup_mandatory_resolver(meta, gql_type) 39 | 40 | for cmd in doctype_resolver_processors: 41 | frappe.get_attr(cmd)(meta=meta, gql_type=gql_type) 42 | 43 | 44 | def setup_frappe_df(meta: Meta, gql_type: GraphQLType): 45 | """ 46 | Sets up frappe-DocField on the GraphQLFields as `frappe_df`. 47 | This is useful when resolving: 48 | - Link / Dynamic Link Fields 49 | - Child Tables 50 | - Checking if the leaf-node is translatable 51 | """ 52 | from .utils import get_default_fields_docfield 53 | fields = meta.fields + get_default_fields_docfield() 54 | for df in fields: 55 | if df.fieldname not in gql_type.fields: 56 | continue 57 | 58 | gql_type.fields[df.fieldname].frappe_df = df 59 | 60 | 61 | def setup_doctype_resolver(meta: Meta, gql_type: GraphQLType): 62 | """ 63 | Sets custom resolver to BaseDocument.doctype field 64 | """ 65 | if "doctype" not in gql_type.fields: 66 | return 67 | 68 | gql_type.fields["doctype"].resolve = _doctype_resolver 69 | 70 | 71 | def setup_mandatory_resolver(meta: Meta, gql_type: GraphQLType): 72 | """ 73 | When mandatory fields return None, it might be due to restricted permlevel access 74 | So when we find a Null value being returned and the field requested is restricted to 75 | the current User, we raise Permission Error instead of: 76 | 77 | "Cannot return null for non-nullable field ..." 78 | 79 | """ 80 | from graphql.execution.execute import default_field_resolver 81 | from .utils import field_permlevel_check 82 | 83 | for df in meta.fields: 84 | if not df.reqd: 85 | continue 86 | 87 | if df.fieldname not in gql_type.fields: 88 | continue 89 | 90 | gql_field = gql_type.fields[df.fieldname] 91 | if not isinstance(gql_field.type, GraphQLNonNull): 92 | continue 93 | 94 | if gql_field.resolve: 95 | gql_field.resolve = field_permlevel_check(gql_field.resolve) 96 | else: 97 | gql_field.resolve = field_permlevel_check(default_field_resolver) 98 | 99 | 100 | def _doctype_resolver(obj, info: GraphQLResolveInfo, **kwargs): 101 | dt = get_singular_doctype(info.parent_type.name) 102 | return dt 103 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/child_tables.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLType, GraphQLResolveInfo 2 | 3 | from frappe.model.meta import Meta 4 | 5 | from .dataloaders import get_child_table_loader 6 | from .utils import get_frappe_df_from_resolve_info 7 | from ..gql_fields import get_doctype_requested_fields 8 | from .. import get_info_path_key 9 | 10 | 11 | def setup_child_table_resolvers(meta: Meta, gql_type: GraphQLType): 12 | for df in meta.get_table_fields(): 13 | if df.fieldname not in gql_type.fields: 14 | continue 15 | 16 | gql_field = gql_type.fields[df.fieldname] 17 | gql_field.resolve = _child_table_resolver 18 | 19 | 20 | def _child_table_resolver(obj, info: GraphQLResolveInfo, **kwargs): 21 | # If the obj already has a non None value, we can return it. 22 | # This happens when the resolver returns a full doc 23 | if obj.get(info.field_name) is not None: 24 | return obj.get(info.field_name) 25 | 26 | df = get_frappe_df_from_resolve_info(info) 27 | if not df: 28 | return [] 29 | 30 | return get_child_table_loader( 31 | child_doctype=df.options, 32 | parent_doctype=df.parent, 33 | parentfield=df.fieldname, 34 | path=get_info_path_key(info), 35 | fields=get_doctype_requested_fields(df.options, info, {"parent"}, df.parent) 36 | ).load(obj.get("name")) 37 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/dataloaders/__init__.py: -------------------------------------------------------------------------------- 1 | from .frappe_dataloader import FrappeDataloader # noqa: F401 2 | from .doctype_loader import get_doctype_dataloader # noqa: F401 3 | from .child_table_loader import get_child_table_loader # noqa: F401 4 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/dataloaders/child_table_loader.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from typing import List 3 | 4 | import frappe 5 | 6 | from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype 7 | from .frappe_dataloader import FrappeDataloader 8 | from .locals import get_loader_from_locals, set_loader_in_locals 9 | 10 | 11 | def get_child_table_loader(child_doctype: str, parent_doctype: str, parentfield: str, 12 | path: str = None, fields: List[str] = None) -> FrappeDataloader: 13 | locals_key = (child_doctype, parent_doctype, parentfield) 14 | if path: 15 | # incase alias usage 16 | locals_key = locals_key + (path,) 17 | loader = get_loader_from_locals(locals_key) 18 | if loader: 19 | return loader 20 | 21 | loader = FrappeDataloader(_get_child_table_loader_fn( 22 | child_doctype=child_doctype, 23 | parent_doctype=parent_doctype, 24 | parentfield=parentfield, 25 | fields=fields 26 | )) 27 | set_loader_in_locals(locals_key, loader) 28 | return loader 29 | 30 | 31 | def _get_child_table_loader_fn(child_doctype: str, parent_doctype: str, parentfield: str, 32 | fields: List[str] = None): 33 | def _inner(keys): 34 | fieldnames = fields or get_allowed_fieldnames_for_doctype( 35 | doctype=child_doctype, 36 | parent_doctype=parent_doctype 37 | ) 38 | 39 | rows = frappe.get_all( 40 | doctype=child_doctype, 41 | fields=fieldnames, 42 | filters=dict( 43 | parenttype=parent_doctype, 44 | parentfield=parentfield, 45 | parent=("in", keys), 46 | ), 47 | order_by="idx asc") 48 | 49 | _results = OrderedDict() 50 | for k in keys: 51 | _results[k] = [] 52 | 53 | for row in rows: 54 | if row.parent not in _results: 55 | continue 56 | _results.get(row.parent).append(row) 57 | 58 | return _results.values() 59 | 60 | return _inner 61 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/dataloaders/doctype_loader.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import frappe 3 | from .frappe_dataloader import FrappeDataloader 4 | from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype 5 | from .locals import get_loader_from_locals, set_loader_in_locals 6 | 7 | 8 | def get_doctype_dataloader(doctype: str, path: str = None, 9 | fields: List[str] = None) -> FrappeDataloader: 10 | """ 11 | Parameters: 12 | doctype: the doctype 13 | path: pass the graphql info path if your dataloader is used multiple time using aliases 14 | fields: fields to fetch 15 | """ 16 | key = doctype 17 | if path: 18 | key += f"-{path}" 19 | loader = get_loader_from_locals(key) 20 | if loader: 21 | return loader 22 | 23 | loader = FrappeDataloader(_get_document_loader_fn(doctype=doctype, fields=fields)) 24 | set_loader_in_locals(key, loader) 25 | return loader 26 | 27 | 28 | def _get_document_loader_fn(doctype: str, fields: List[str] = None): 29 | fieldnames = fields or get_allowed_fieldnames_for_doctype(doctype) 30 | 31 | def _load_documents(keys: List[str]): 32 | docs = frappe.get_list( 33 | doctype=doctype, 34 | filters=[["name", "IN", keys]], 35 | fields=fieldnames, 36 | limit_page_length=len(keys) + 1 37 | ) 38 | 39 | sorted_docs = [] 40 | for k in keys: 41 | doc = [x for x in docs if x.name == k] 42 | if not len(doc): 43 | sorted_docs.append(None) 44 | continue 45 | 46 | sorted_docs.append(doc[0]) 47 | docs.remove(doc[0]) 48 | 49 | return sorted_docs 50 | 51 | return _load_documents 52 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/dataloaders/frappe_dataloader.py: -------------------------------------------------------------------------------- 1 | from graphql_sync_dataloaders import SyncDataLoader 2 | 3 | 4 | class FrappeDataloader(SyncDataLoader): 5 | def dispatch_queue(self): 6 | """ 7 | We hope to clear the cache after each batch load 8 | This is helpful when we ask for the same Document consecutively 9 | with Updates in between in a single request 10 | 11 | Eg: 12 | - get_doctype_dataloader("User").load("Administrator") 13 | - frappe.db.set_value("User", "Administrator", "first_name", "New Name") 14 | - get_doctype_dataloader("User").load("Administrator") 15 | 16 | If we do not clear the cache, the second load will return the old value 17 | """ 18 | super().dispatch_queue() 19 | self._cache = {} 20 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/dataloaders/locals.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Tuple 2 | 3 | import frappe 4 | from .frappe_dataloader import FrappeDataloader 5 | 6 | 7 | def get_loader_from_locals(key: Union[str, Tuple[str, ...]]) -> Union[FrappeDataloader, None]: 8 | if not hasattr(frappe.local, "dataloaders"): 9 | frappe.local.dataloaders = frappe._dict() 10 | 11 | if key in frappe.local.dataloaders: 12 | return frappe.local.dataloaders.get(key) 13 | 14 | 15 | def set_loader_in_locals(key: Union[str, Tuple[str, ...]], loader: FrappeDataloader): 16 | if not hasattr(frappe.local, "dataloaders"): 17 | frappe.local.dataloaders = frappe._dict() 18 | 19 | frappe.local.dataloaders[key] = loader 20 | 21 | 22 | def clear_all_loaders(): 23 | if hasattr(frappe.local, "dataloaders"): 24 | frappe.local.dataloaders = frappe._dict() 25 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/link_field.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLResolveInfo, GraphQLType, is_scalar_type 2 | 3 | from frappe.model.meta import Meta 4 | 5 | from .dataloaders import get_doctype_dataloader 6 | from .utils import get_frappe_df_from_resolve_info 7 | from ..gql_fields import get_doctype_requested_fields 8 | from .. import get_info_path_key 9 | 10 | 11 | def setup_link_field_resolvers(meta: Meta, gql_type: GraphQLType): 12 | """ 13 | This will set up Link fields on DocTypes to resolve target docs 14 | """ 15 | link_dfs = meta.get_link_fields() + meta.get_dynamic_link_fields() + \ 16 | _get_default_field_links() 17 | 18 | for df in link_dfs: 19 | if df.fieldname not in gql_type.fields or is_scalar_type( 20 | gql_type.fields[df.fieldname].type): 21 | continue 22 | 23 | gql_field = gql_type.fields[df.fieldname] 24 | if df.fieldtype == "Link": 25 | gql_field.resolve = _resolve_link_field 26 | elif df.fieldtype == "Dynamic Link": 27 | gql_field.resolve = _resolve_dynamic_link_field 28 | else: 29 | continue 30 | 31 | _name_df = f"{df.fieldname}__name" 32 | if _name_df not in gql_type.fields: 33 | continue 34 | 35 | gql_type.fields[_name_df].resolve = _resolve_link_name_field 36 | 37 | 38 | def _resolve_link_field(obj, info: GraphQLResolveInfo, **kwargs): 39 | df = get_frappe_df_from_resolve_info(info) 40 | if not df: 41 | return None 42 | 43 | dt = df.options 44 | dn = obj.get(info.field_name) 45 | 46 | if not (dt and dn): 47 | return None 48 | 49 | # Permission check is done within get_doctype_dataloader via get_list 50 | return get_doctype_dataloader(dt, 51 | get_info_path_key(info), 52 | get_doctype_requested_fields(dt, info)).load(dn) 53 | 54 | 55 | def _resolve_dynamic_link_field(obj, info: GraphQLResolveInfo, **kwargs): 56 | df = get_frappe_df_from_resolve_info(info) 57 | if not df: 58 | return None 59 | 60 | dt = obj.get(df.options) 61 | if not dt: 62 | return None 63 | 64 | dn = obj.get(info.field_name) 65 | if not dn: 66 | return None 67 | 68 | # Permission check is done within get_doctype_dataloader via get_list 69 | return get_doctype_dataloader( 70 | dt, get_info_path_key(info), 71 | get_doctype_requested_fields(dt, info)).load(dn) 72 | 73 | 74 | def _resolve_link_name_field(obj, info: GraphQLResolveInfo, **kwargs): 75 | df = info.field_name.split("__name")[0] 76 | return obj.get(df) 77 | 78 | 79 | def _get_default_field_links(): 80 | from .utils import get_default_fields_docfield 81 | 82 | return [ 83 | x for x in get_default_fields_docfield() 84 | if x.fieldtype in ["Link", "Dynamic Link"] 85 | ] 86 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/root_query.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLSchema, GraphQLResolveInfo 2 | 3 | import frappe 4 | from frappe.model.meta import is_single 5 | 6 | from frappe_graphql import CursorPaginator 7 | 8 | from .utils import get_singular_doctype, get_plural_doctype 9 | 10 | 11 | def setup_root_query_resolvers(schema: GraphQLSchema): 12 | """ 13 | This will handle DocType Query at the root. 14 | 15 | Query { 16 | User(name: ID): User! 17 | Users(**args: CursorArgs): UserCountableConnection! 18 | } 19 | """ 20 | 21 | for fieldname, field in schema.query_type.fields.items(): 22 | dt = get_singular_doctype(fieldname) 23 | if dt: 24 | field.resolve = _get_doc_resolver 25 | continue 26 | 27 | dt = get_plural_doctype(fieldname) 28 | if dt: 29 | field.resolve = _doc_cursor_resolver 30 | 31 | 32 | def _get_doc_resolver(obj, info: GraphQLResolveInfo, **kwargs): 33 | dt = get_singular_doctype(info.field_name) 34 | if is_single(dt): 35 | kwargs["name"] = dt 36 | 37 | dn = kwargs["name"] 38 | if not frappe.has_permission(doctype=dt, doc=dn): 39 | raise frappe.PermissionError(frappe._("No permission for {0}").format(dt + " " + dn)) 40 | 41 | doc = frappe.get_doc(dt, dn) 42 | doc.apply_fieldlevel_read_permissions() 43 | return doc 44 | 45 | 46 | def _doc_cursor_resolver(obj, info: GraphQLResolveInfo, **kwargs): 47 | plural_doctype = get_plural_doctype(info.field_name) 48 | 49 | frappe.has_permission( 50 | doctype=plural_doctype, 51 | throw=True) 52 | 53 | return CursorPaginator(doctype=plural_doctype).resolve(obj, info, **kwargs) 54 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/select_fields.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLType, GraphQLResolveInfo, GraphQLNonNull, GraphQLEnumType 2 | 3 | import frappe 4 | from frappe.model.meta import Meta 5 | 6 | from .translate import _translatable_resolver 7 | from .utils import get_frappe_df_from_resolve_info 8 | 9 | 10 | def setup_select_field_resolvers(meta: Meta, gql_type: GraphQLType): 11 | 12 | for df in meta.get_select_fields(): 13 | 14 | if df.fieldname not in gql_type.fields: 15 | continue 16 | 17 | gql_field = gql_type.fields[df.fieldname] 18 | gql_field.resolve = _select_field_resolver 19 | 20 | 21 | def _select_field_resolver(obj, info: GraphQLResolveInfo, **kwargs): 22 | 23 | df = get_frappe_df_from_resolve_info(info) 24 | return_type = info.return_type 25 | 26 | value = obj.get(info.field_name) 27 | if isinstance(return_type, GraphQLNonNull): 28 | return_type = return_type.of_type 29 | 30 | if isinstance(return_type, GraphQLEnumType): 31 | return frappe.scrub(value).upper() 32 | 33 | if df and df.translatable: 34 | return _translatable_resolver(obj, info, **kwargs) 35 | 36 | return obj.get(info.field_name) 37 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/tests/test_document_resolver.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import frappe 3 | 4 | from frappe_graphql.graphql import get_schema, execute 5 | from graphql import GraphQLArgument, GraphQLField, GraphQLScalarType, GraphQLString, GraphQLEnumType 6 | 7 | """ 8 | The following aspects of Document Resolver is tested here: 9 | - BASIC_TESTS ✔️ 10 | - LINK_FIELD_TESTS ✔️ 11 | - DYNAMIC_LINK_FIELD_TESTS ⌛ 12 | - CHILD_TABLE_TESTS ✔️ 13 | - SELECT_FIELD_TESTS ✔️ 14 | - IGNORE_PERMS_TESTS ✔️ 15 | - DB_DELETED_DOC_TESTS ✔️ 16 | - TRANSLATION_TESTS ⌛ 17 | - OWNER / MODIFIED_BY TESTS ⌛ 18 | 19 | You can search for any one of the above keys to jump to related tests 20 | """ 21 | 22 | 23 | class TestDocumentResolver(unittest.TestCase): 24 | 25 | ADMIN_DOCNAME = "administrator" 26 | 27 | def tearDown(self) -> None: 28 | if frappe.local.user != "Administrator": 29 | frappe.set_user("Administrator") 30 | 31 | """ 32 | BASIC_TESTS 33 | """ 34 | 35 | def test_get_administrator(self): 36 | """ 37 | Test basic get_doc 38 | """ 39 | r = execute( 40 | query=""" 41 | query FetchAdmin($user: String!) { 42 | User(name: $user) { 43 | doctype 44 | name 45 | email 46 | full_name 47 | } 48 | } 49 | """, 50 | variables={ 51 | "user": self.ADMIN_DOCNAME 52 | } 53 | ) 54 | self.assertIsNone(r.get("errors")) 55 | self.assertIsInstance(r.get("data"), dict) 56 | self.assertIsInstance(r.get("data").get("User", None), dict) 57 | 58 | admin = r.get("data").get("User") 59 | self.assertEqual(admin.get("doctype"), "User") 60 | self.assertEqual(admin.get("name"), "Administrator") 61 | self.assertEqual(admin.get("full_name"), "Administrator") 62 | 63 | def test_enum_type_with_doctype_name(self): 64 | """ 65 | Default Resolvers are setup only for Doctype GQLTypes 66 | The GQLType name is used to determine if it's a DocType or not. 67 | 68 | There are cases where some other GQLType (eg: GQLEnumType) is named as a DocType. 69 | In such cases, the default resolver should not be set. Trying to set it will result in 70 | an error. 71 | 72 | We will make a GQLEnumType named "ToDo" which is a valid Frappe Doctype. 73 | We will try to ping on the schema to validate no errors are thrown. 74 | """ 75 | from .. import setup_default_resolvers 76 | 77 | schema = get_schema() 78 | todo_enum = GraphQLEnumType( 79 | name="ToDo", 80 | values={ 81 | "TODO": "TODO", 82 | "DONE": "DONE" 83 | } 84 | ) 85 | schema.type_map["ToDo"] = todo_enum 86 | 87 | # Rerun setup_default_resolvers 88 | setup_default_resolvers(schema) 89 | 90 | # Run Ping! to validate schema 91 | r = execute( 92 | query=""" 93 | query Ping { 94 | ping 95 | } 96 | """ 97 | ) 98 | self.assertIsNone(r.get("errors")) 99 | self.assertEqual(r.get("data").get("ping"), "pong") 100 | 101 | """ 102 | LINK_FIELD_TESTS 103 | """ 104 | 105 | def test_link_fields(self): 106 | """ 107 | Test User.language 108 | Set User.Administrator.language to en if not set already 109 | """ 110 | if not frappe.db.get_value("User", self.ADMIN_DOCNAME, "language"): 111 | frappe.db.set_value("User", self.ADMIN_DOCNAME, "language", "en") 112 | 113 | r = execute( 114 | query=""" 115 | query FetchAdmin($user: String!) { 116 | User(name: $user) { 117 | doctype 118 | name 119 | email 120 | full_name 121 | language__name 122 | language { 123 | name 124 | language_name 125 | } 126 | } 127 | } 128 | """, 129 | variables={ 130 | "user": self.ADMIN_DOCNAME 131 | } 132 | ) 133 | self.assertIsNone(r.get("errors")) 134 | 135 | admin = r.get("data").get("User") 136 | self.assertIsNotNone(admin.get("language")) 137 | self.assertEqual(admin.get("language__name"), admin.get("language").get("name")) 138 | 139 | lang = admin.get("language") 140 | self.assertEqual( 141 | lang.get("language_name"), 142 | frappe.db.get_value("Language", lang.get("name"), "language_name") 143 | ) 144 | 145 | def test_child_table_link_fields(self): 146 | """ 147 | Test user.roles.role__name is equal to user.roles.role.name 148 | """ 149 | r = execute( 150 | query=""" 151 | query FetchAdmin($user: String!) { 152 | User(name: $user) { 153 | doctype 154 | name 155 | full_name 156 | roles { 157 | role__name 158 | role { 159 | name 160 | } 161 | } 162 | } 163 | } 164 | """, 165 | variables={ 166 | "user": "administrator" 167 | } 168 | ) 169 | self.assertIsNone(r.get("errors")) 170 | self.assertIsInstance(r.get("data"), dict) 171 | self.assertIsInstance(r.get("data").get("User", None), dict) 172 | 173 | admin = r.get("data").get("User") 174 | for role in admin.get("roles"): 175 | self.assertEqual(role.get("role__name"), 176 | role.get("role").get("name")) 177 | 178 | """ 179 | CHILD_TABLE_TESTS 180 | """ 181 | 182 | def test_child_table(self): 183 | """ 184 | Test user.roles 185 | """ 186 | r = execute( 187 | query=""" 188 | query FetchAdmin($user: String!) { 189 | User(name: $user) { 190 | doctype 191 | name 192 | full_name 193 | roles { 194 | doctype name 195 | parent__name parenttype parentfield 196 | role__name 197 | role { 198 | name 199 | } 200 | } 201 | } 202 | } 203 | """, 204 | variables={ 205 | "user": "administrator" 206 | } 207 | ) 208 | self.assertIsNone(r.get("errors")) 209 | 210 | admin = r.get("data").get("User") 211 | for role in admin.get("roles"): 212 | self.assertEqual(role.get("doctype"), "Has Role") 213 | 214 | self.assertEqual(role.get("parenttype"), admin.get("doctype")) 215 | self.assertEqual(role.get("parent__name").lower(), admin.get("name").lower()) 216 | self.assertEqual(role.get("parentfield"), "roles") 217 | 218 | self.assertEqual(role.get("role__name"), 219 | role.get("role").get("name")) 220 | """ 221 | SELECT_FIELD_TESTS 222 | """ 223 | 224 | def test_simple_select(self): 225 | # Make sure the field is a String field 226 | schema = get_schema() 227 | user_type = schema.type_map.get("User") 228 | original_type = None 229 | if not isinstance(user_type.fields.get("desk_theme").type, GraphQLScalarType): 230 | original_type = user_type.fields.get("desk_theme").type 231 | user_type.fields.get("desk_theme").type = GraphQLString 232 | 233 | r = execute( 234 | query=""" 235 | query FetchAdmin($user: String!) { 236 | User(name: $user) { 237 | full_name 238 | desk_theme 239 | } 240 | } 241 | """, 242 | variables={ 243 | "user": "administrator" 244 | } 245 | ) 246 | 247 | self.assertIsNone(r.get("errors")) 248 | admin = r.get("data").get("User") 249 | 250 | self.assertIn(admin.get("desk_theme"), ["Light", "Dark"]) 251 | 252 | # Set back the original type 253 | if original_type is not None: 254 | user_type.fields.get("desk_theme").type = original_type 255 | 256 | def test_enum_select(self): 257 | """ 258 | Update SDL.User.desk_theme return type to be an Enum 259 | """ 260 | from graphql import GraphQLScalarType, GraphQLEnumType 261 | schema = get_schema() 262 | user_type = schema.type_map.get("User") 263 | original_type = None 264 | if isinstance(user_type.fields.get("desk_theme").type, GraphQLScalarType): 265 | original_type = user_type.fields.get("desk_theme").type 266 | user_type.fields.get("desk_theme").type = GraphQLEnumType( 267 | name="UserDeskThemeType", 268 | values={ 269 | "DARK": "DARK", 270 | "LIGHT": "LIGHT" 271 | } 272 | ) 273 | 274 | r = execute( 275 | query=""" 276 | query FetchAdmin($user: String!) { 277 | User(name: $user) { 278 | full_name 279 | desk_theme 280 | } 281 | } 282 | """, 283 | variables={ 284 | "user": "administrator" 285 | } 286 | ) 287 | 288 | self.assertIsNone(r.get("errors")) 289 | admin = r.get("data").get("User") 290 | 291 | self.assertIn(admin.get("desk_theme"), ["LIGHT", "DARK"]) 292 | 293 | # Set back the original type 294 | if original_type is not None: 295 | user_type.fields.get("desk_theme").type = original_type 296 | 297 | """ 298 | DB_DELETED_DOC_TESTS 299 | """ 300 | 301 | def test_deleted_doc_resolution(self): 302 | d = frappe.get_doc(dict( 303 | doctype="Role", 304 | role_name="Example A", 305 | )).insert() 306 | 307 | d.delete() 308 | 309 | # We cannot call Query.Role(name: d.name) now since its deleted 310 | schema = get_schema() 311 | schema.type_map["RoleDocInput"] = GraphQLScalarType( 312 | name="RoleDocInput" 313 | ) 314 | schema.query_type.fields["EchoRole"] = GraphQLField( 315 | type_=schema.type_map["Role"], 316 | args=dict( 317 | role=GraphQLArgument( 318 | type_=schema.type_map["RoleDocInput"] 319 | ) 320 | ), 321 | resolve=lambda obj, info, **kwargs: kwargs.get("role") 322 | ) 323 | 324 | r = execute( 325 | query=""" 326 | query EchoRole($role: RoleDocInput!) { 327 | EchoRole(role: $role) { 328 | doctype 329 | name 330 | role_name 331 | } 332 | } 333 | """, 334 | variables={ 335 | "role": d 336 | } 337 | ) 338 | resolved_doc = frappe._dict(r.get("data").get("EchoRole")) 339 | 340 | self.assertEqual(resolved_doc.doctype, d.doctype) 341 | self.assertEqual(resolved_doc.name, d.name) 342 | self.assertEqual(resolved_doc.role_name, d.role_name) 343 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/translate.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLResolveInfo, GraphQLType 2 | 3 | import frappe 4 | from frappe.model.meta import Meta 5 | 6 | 7 | def setup_translatable_resolvers(meta: Meta, gql_type: GraphQLType): 8 | for df_fieldname in meta.get_translatable_fields(): 9 | if df_fieldname not in gql_type.fields: 10 | continue 11 | 12 | gql_field = gql_type.fields[df_fieldname] 13 | 14 | if gql_field.resolve: 15 | continue 16 | 17 | gql_field.resolve = _translatable_resolver 18 | 19 | 20 | def _translatable_resolver(obj, info: GraphQLResolveInfo, **kwargs): 21 | value = obj.get(info.field_name) 22 | if isinstance(value, str) and value: 23 | value = frappe._(value) 24 | 25 | return value 26 | -------------------------------------------------------------------------------- /frappe_graphql/utils/resolver/utils.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLResolveInfo 2 | 3 | import frappe 4 | 5 | from frappe_graphql.utils.permissions import is_field_permlevel_restricted_for_doctype 6 | 7 | 8 | SINGULAR_DOCTYPE_MAP_REDIS_KEY = "singular_doctype_graphql_map" 9 | PLURAL_DOCTYPE_MAP_REDIS_KEY = "plural_doctype_graphql_map" 10 | 11 | 12 | def get_singular_doctype(name): 13 | singular_map = frappe.cache().get_value(SINGULAR_DOCTYPE_MAP_REDIS_KEY) 14 | if not singular_map: 15 | import inflect 16 | p = inflect.engine() 17 | 18 | valid_doctypes = [x.name for x in frappe.get_all("DocType")] 19 | singular_map = frappe._dict() 20 | for dt in valid_doctypes: 21 | 22 | # IF plural = singular, lets add a prefix: 'A' 23 | if p.plural(dt) == dt: 24 | prefix = "A" 25 | if dt[0].lower() in ("a", "e", "i", "o", "u"): 26 | prefix = "An" 27 | 28 | singular_map[f"{prefix}{dt.replace(' ', '')}"] = dt 29 | else: 30 | singular_map[dt.replace(" ", "")] = dt 31 | 32 | frappe.cache().set_value(SINGULAR_DOCTYPE_MAP_REDIS_KEY, singular_map) 33 | 34 | return singular_map.get(name, None) 35 | 36 | 37 | def get_plural_doctype(name): 38 | plural_map = frappe.cache().get_value(PLURAL_DOCTYPE_MAP_REDIS_KEY) 39 | if not plural_map: 40 | import inflect 41 | p = inflect.engine() 42 | valid_doctypes = [x.name for x in frappe.get_all("DocType")] 43 | plural_map = frappe._dict() 44 | for dt in valid_doctypes: 45 | plural_map[p.plural(dt).replace(" ", "")] = dt 46 | 47 | frappe.cache().set_value(PLURAL_DOCTYPE_MAP_REDIS_KEY, plural_map) 48 | 49 | return plural_map.get(name, None) 50 | 51 | 52 | def get_frappe_df_from_resolve_info(info: GraphQLResolveInfo): 53 | return getattr(info.parent_type.fields[info.field_name], "frappe_df", None) 54 | 55 | 56 | def field_permlevel_check(resolver): 57 | """ 58 | A helper function when wrapped will check if the field 59 | being resolved is permlevel restricted & GQLNonNullField 60 | 61 | If permlevel restriction is applied on the field, None is returned. 62 | This will raise 'You cannot return Null on a NonNull field' error. 63 | This helper function will change it to a permission error. 64 | """ 65 | import functools 66 | 67 | @functools.wraps(resolver) 68 | def _inner(obj, info: GraphQLResolveInfo, **kwargs): 69 | value = obj.get(info.field_name) 70 | if value is not None: 71 | return resolver(obj, info, **kwargs) 72 | 73 | # Ok, so value is None, and this field is Non-Null 74 | df = get_frappe_df_from_resolve_info(info) 75 | if not df or not df.parent: 76 | return 77 | 78 | dt = df.parent 79 | parent_dt = obj.get("parenttype") 80 | 81 | is_permlevel_restricted = is_field_permlevel_restricted_for_doctype( 82 | fieldname=info.field_name, doctype=dt, parent_doctype=parent_dt) 83 | 84 | if is_permlevel_restricted: 85 | raise frappe.PermissionError(frappe._( 86 | "You do not have read permission on field '{0}' in DocType '{1}'" 87 | ).format( 88 | info.field_name, 89 | "{} ({})".format(dt, parent_dt) if parent_dt else dt 90 | )) 91 | 92 | return resolver(obj, info, **kwargs) 93 | 94 | return _inner 95 | 96 | 97 | def get_default_fields_docfield(): 98 | """ 99 | from frappe.model import default_fields are included on all DocTypes 100 | But, DocMeta do not include them in the fields 101 | """ 102 | from frappe.model import default_fields 103 | 104 | def _get_default_field_df(fieldname): 105 | df = frappe._dict( 106 | fieldname=fieldname, 107 | fieldtype="Data" 108 | ) 109 | if fieldname in ("owner", "modified_by"): 110 | df.fieldtype = "Link" 111 | df.options = "User" 112 | 113 | if fieldname == "parent": 114 | df.fieldtype = "Dynamic Link" 115 | df.options = "parenttype" 116 | 117 | if fieldname in ["docstatus", "idx"]: 118 | df.fieldtype = "Int" 119 | 120 | return df 121 | 122 | return [ 123 | _get_default_field_df(x) for x in default_fields 124 | ] 125 | -------------------------------------------------------------------------------- /frappe_graphql/utils/roles.py: -------------------------------------------------------------------------------- 1 | from typing import Union, List 2 | 3 | import frappe 4 | 5 | 6 | def REQUIRE_ROLES(role: Union[str, List[str]], exc=frappe.PermissionError): 7 | def inner(func): 8 | def wrapper(*args, **kwargs): 9 | nonlocal exc 10 | 11 | roles = set() 12 | if isinstance(role, str): 13 | roles.add(role) 14 | elif isinstance(role, list): 15 | roles.update(role) 16 | 17 | roles.difference_update(frappe.get_roles()) 18 | 19 | if len(roles): 20 | exc = exc or frappe.PermissionError 21 | raise exc(frappe._("Permission Denied")) 22 | 23 | return func(*args, **kwargs) 24 | 25 | return wrapper 26 | 27 | return inner 28 | -------------------------------------------------------------------------------- /frappe_graphql/utils/subscriptions.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from graphql import GraphQLResolveInfo, ExecutionContext, DocumentNode, GraphQLField, \ 3 | FieldNode, GraphQLError, parse 4 | 5 | import frappe 6 | from frappe.realtime import emit_via_redis 7 | from frappe.utils import now_datetime, get_datetime 8 | 9 | from frappe_graphql import get_schema 10 | 11 | """ 12 | Implemented similar to 13 | https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md 14 | 15 | Server --> Client Message Types: 16 | - GQL_DATA 17 | - GQL_COMPLETE 18 | 19 | Only the above two are implemented as of now. Once we have a mechanism for 20 | SocketIO -> Python communication in frappe, we can implement the complete spec 21 | which includes types like: 22 | - GQL_START 23 | - GQL_STOP 24 | - GQL_CONNECTION_ACK 25 | - GQL_CONNECTION_KEEP_ALIVE 26 | """ 27 | 28 | 29 | def setup_subscription(subscription, info: GraphQLResolveInfo, variables, complete_on_error=False): 30 | """ 31 | Set up a frappe task room for the subscription 32 | Args: 33 | subscription: The name of the subscription, usually the field name itself 34 | info: The graphql resolve info 35 | variables: incoming variable dict object 36 | complete_on_error: Stop / Send Completed Event on GQL Error 37 | 38 | Returns: 39 | Subscription info, including the subscription_id 40 | """ 41 | excluded_field_nodes = filter_selection_set(info) 42 | variables = frappe._dict(variables) 43 | subscription_id = frappe.generate_hash(f"{subscription}-{frappe.session.user}", length=8) 44 | 45 | subscription_data = frappe._dict( 46 | subscribed_at=now_datetime(), 47 | last_ping=now_datetime(), 48 | variables=variables, 49 | subscription_id=subscription_id, 50 | selection_set=excluded_field_nodes, 51 | user=frappe.session.user, 52 | complete_on_error=complete_on_error 53 | ) 54 | 55 | frappe.cache().hset( 56 | get_subscription_redis_key(subscription), subscription_id, subscription_data) 57 | 58 | return frappe._dict( 59 | subscription_id=subscription_id 60 | ) 61 | 62 | 63 | def get_consumers(subscription): 64 | """ 65 | Gets a list of consumers subscribed to a particular subscription 66 | Args: 67 | subscription: The name of the subscription 68 | 69 | Returns: 70 | A list of consumers with their subscription info 71 | """ 72 | redis_key = get_subscription_redis_key(subscription) 73 | consumers = frappe.cache().hgetall(redis_key) 74 | return consumers.values() 75 | 76 | 77 | def notify_consumer(subscription, subscription_id, data): 78 | consumer = frappe.cache().hget( 79 | get_subscription_redis_key(subscription), 80 | subscription_id 81 | ) 82 | if not consumer: 83 | return 84 | 85 | original_user = frappe.session.user 86 | frappe.set_user(consumer.user) 87 | 88 | execution_data = gql_transform(subscription, consumer.selection_set, data or frappe._dict()) 89 | response = frappe._dict( 90 | type="GQL_DATA", 91 | id=subscription_id, 92 | payload=execution_data 93 | ) 94 | 95 | room = get_task_room(subscription_id) 96 | emit_via_redis( 97 | event=subscription, 98 | message=response, 99 | room=room 100 | ) 101 | 102 | if len(execution_data.get("errors") or []): 103 | log_error( 104 | subscription=subscription, 105 | subscription_id=subscription_id, 106 | output=execution_data) 107 | if consumer.complete_on_error: 108 | complete_subscription(subscription=subscription, subscription_id=subscription_id) 109 | 110 | frappe.set_user(original_user) 111 | 112 | 113 | def complete_subscription(subscription, subscription_id, data=None): 114 | consumer = frappe.cache().hget( 115 | get_subscription_redis_key(subscription), 116 | subscription_id 117 | ) 118 | if not consumer: 119 | return 120 | 121 | response = frappe._dict( 122 | id="GQL_COMPLETE", 123 | payload=data 124 | ) 125 | 126 | room = get_task_room(subscription_id) 127 | emit_via_redis( 128 | event=subscription, 129 | message=response, 130 | room=room 131 | ) 132 | frappe.cache().hdel(get_subscription_redis_key(subscription), subscription_id) 133 | 134 | 135 | def notify_consumers(subscription, subscription_ids, data): 136 | """ 137 | Notify a set of consumers 138 | Args: 139 | subscription: The name of the subscription 140 | subscription_ids: List[str] of Subscription Ids 141 | data: The event data to send 142 | """ 143 | 144 | for id in subscription_ids: 145 | notify_consumer( 146 | subscription=subscription, 147 | subscription_id=id, 148 | data=data) 149 | 150 | 151 | def notify_all_consumers(subscription, data): 152 | """ 153 | Notify all Consumers of subscription 154 | Args: 155 | subscription: The name of the subscription 156 | data: The event data to send every consumer 157 | """ 158 | for consumer in get_consumers(subscription): 159 | notify_consumer( 160 | subscription=subscription, 161 | subscription_id=consumer.subscription_id, 162 | data=data) 163 | 164 | 165 | def gql_transform(subscription, selection_set, obj): 166 | if not obj or not isinstance(selection_set, list): 167 | return obj 168 | 169 | schema = get_schema() 170 | schema.query_type.fields["__subscription__"] = GraphQLField( 171 | type_=schema.subscription_type.fields[subscription].type 172 | ) 173 | 174 | document: DocumentNode = parse(""" 175 | query { 176 | __subscription__ { 177 | subscription_id 178 | } 179 | } 180 | """) 181 | subscription_field_node = document.definitions[0].selection_set.selections[0] 182 | subscription_field_node.selection_set.selections = selection_set 183 | 184 | exc_ctx = ExecutionContext.build( 185 | schema=schema, 186 | document=document, 187 | ) 188 | data = exc_ctx.execute_operation(exc_ctx.operation, frappe._dict(__subscription__=obj)) 189 | result = frappe._dict(exc_ctx.build_response(data).formatted) 190 | 191 | # Cleanup 192 | del schema.query_type.fields["__subscription__"] 193 | if result.get("data") and result.get("data").get("__subscription__"): 194 | result.get("data")[subscription] = result.get("data").get("__subscription__") 195 | del result.get("data")["__subscription__"] 196 | 197 | return result 198 | 199 | 200 | def log_error(subscription, subscription_id, output): 201 | import traceback as tb 202 | 203 | consumer = frappe.cache().hget( 204 | get_subscription_redis_key(subscription), 205 | subscription_id 206 | ) 207 | tracebacks = [] 208 | for idx, err in enumerate(output.errors): 209 | if not isinstance(err, GraphQLError): 210 | continue 211 | 212 | exc = err.original_error 213 | if not exc: 214 | continue 215 | tracebacks.append( 216 | f"GQLError #{idx}\n" 217 | + f"{str(err)}\n\n" 218 | + f"{''.join(tb.format_exception(exc, exc, exc.__traceback__))}" 219 | ) 220 | 221 | tracebacks.append(f"Frappe Traceback: \n{frappe.get_traceback()}") 222 | if frappe.conf.get("developer_mode"): 223 | frappe.errprint(tracebacks) 224 | 225 | tracebacks = "\n==========================================\n".join(tracebacks) 226 | error_log = frappe.new_doc("GraphQL Error Log") 227 | error_log.update(frappe._dict( 228 | title="GraphQL Subscription Error", 229 | query="-- subscription --", 230 | operation_name=subscription, 231 | variables=frappe.as_json(consumer.variables), 232 | output=frappe.as_json(output), 233 | traceback=tracebacks 234 | )) 235 | error_log.insert(ignore_permissions=True) 236 | 237 | 238 | def filter_selection_set(info: GraphQLResolveInfo): 239 | """ 240 | This will clear all non subscription_id fields from the response 241 | Since a subscription operation type is a single root field operation, 242 | it is safe to assume that there is going to be only a single subscription per operation 243 | http://spec.graphql.org/June2018/#sec-Subscription-Operation-Definitions 244 | """ 245 | from graphql import Location 246 | from .pyutils import unfreeze 247 | 248 | excluded_field_nodes = [] 249 | 250 | def _should_include(field_node: FieldNode): 251 | if not field_node.name: 252 | # Unknown field_node type 253 | return True 254 | if field_node.name.value == "subscription_id": 255 | return True 256 | 257 | # Location is a highly nested AST type 258 | excluded_field_nodes.append(unfreeze(field_node, ignore_types=[Location])) 259 | return False 260 | 261 | info.field_nodes[0].selection_set.selections = [ 262 | x for x in info.field_nodes[0].selection_set.selections if _should_include(x)] 263 | 264 | return excluded_field_nodes 265 | 266 | 267 | def remove_inactive_consumers(): 268 | """ 269 | Removes Inactive Consumers if they don't send in a ping 270 | within the THRESHOLD_MINUTES time 271 | """ 272 | 273 | THRESHOLD_MINUTES = 5 274 | 275 | schema = get_schema() 276 | for subscription in schema.subscription_type.fields.keys(): 277 | to_remove = [] 278 | for consumer in frappe.cache().hkeys(get_subscription_redis_key(subscription)): 279 | subscription_info = frappe.cache().hget( 280 | get_subscription_redis_key(subscription), consumer) 281 | 282 | should_remove = True 283 | if subscription_info.last_ping: 284 | last_ping = get_datetime(subscription_info.last_ping) 285 | if last_ping + timedelta(minutes=THRESHOLD_MINUTES) >= now_datetime(): 286 | should_remove = False 287 | 288 | if should_remove: 289 | to_remove.append(consumer) 290 | 291 | if len(to_remove): 292 | frappe.cache().hdel( 293 | get_subscription_redis_key(subscription), *to_remove) 294 | 295 | 296 | @frappe.whitelist(allow_guest=True) 297 | def subscription_keepalive(subscription, subscription_id): 298 | schema = get_schema() 299 | if subscription not in schema.subscription_type.fields: 300 | frappe.throw("{} is not a valid subscription".format(subscription)) 301 | 302 | subscription_info = frappe.cache().hget( 303 | get_subscription_redis_key(subscription), subscription_id) 304 | 305 | if not subscription_info: 306 | frappe.throw( 307 | "No consumer: {} registered for subscription: {}".format( 308 | subscription_id, subscription)) 309 | 310 | subscription_info.last_ping = now_datetime() 311 | frappe.cache().hset( 312 | get_subscription_redis_key(subscription), subscription_id, subscription_info) 313 | 314 | return subscription_info 315 | 316 | 317 | def get_subscription_redis_key(name): 318 | return f"gql_subscription_{name}" 319 | 320 | 321 | def get_task_room(task_id): 322 | return f"{frappe.local.site}:task_progress:{task_id}" 323 | -------------------------------------------------------------------------------- /frappe_graphql/utils/tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import patch 3 | 4 | import frappe 5 | from frappe.model import no_value_fields, default_fields 6 | 7 | from ..permissions import get_allowed_fieldnames_for_doctype 8 | 9 | 10 | class TestGetAllowedFieldNameForDocType(TestCase): 11 | def setUp(self) -> None: 12 | pass 13 | 14 | def tearDown(self) -> None: 15 | # Clear caches 16 | frappe.local.meta_cache = frappe._dict() 17 | frappe.local.permlevel_fields = {} 18 | 19 | frappe.set_user("Administrator") 20 | 21 | def test_admin_on_user(self): 22 | """ 23 | Administrator on User doctype 24 | """ 25 | meta = frappe.get_meta("User") 26 | fieldnames = get_allowed_fieldnames_for_doctype("User") 27 | self.assertCountEqual( 28 | fieldnames, 29 | [x.fieldname for x in meta.fields if x.fieldtype not in no_value_fields] 30 | + [x for x in default_fields if x != "doctype"] 31 | ) 32 | 33 | def test_perm_level_on_guest(self): 34 | frappe.set_user("Guest") 35 | 36 | # Guest is given permlevel=0 access on User DocType 37 | user_meta = self._get_custom_user_meta() 38 | 39 | with patch("frappe.get_meta") as get_meta_mock: 40 | get_meta_mock.return_value = user_meta 41 | fieldnames = get_allowed_fieldnames_for_doctype(user_meta.name) 42 | 43 | self.maxDiff = None 44 | self.assertCountEqual( 45 | fieldnames, 46 | [x.fieldname for x in user_meta.fields 47 | if x.permlevel == 0 and x.fieldtype not in no_value_fields] 48 | + [x for x in default_fields if x != "doctype"] 49 | ) 50 | 51 | def test_perm_level_on_guest_1(self): 52 | frappe.set_user("Guest") 53 | 54 | # Guest is given permlevel=1 access on User DocType 55 | user_meta = self._get_custom_user_meta() 56 | user_meta.permissions.append(dict( 57 | role="Guest", 58 | read=1, 59 | permlevel=1 60 | )) 61 | 62 | with patch("frappe.get_meta") as get_meta_mock: 63 | get_meta_mock.return_value = user_meta 64 | fieldnames = get_allowed_fieldnames_for_doctype(user_meta.name) 65 | 66 | self.maxDiff = None 67 | self.assertCountEqual( 68 | fieldnames, 69 | [x.fieldname for x in user_meta.fields 70 | if x.permlevel in (0, 1) and x.fieldtype not in no_value_fields] 71 | + [x for x in default_fields if x != "doctype"] 72 | ) 73 | 74 | def test_on_child_doctype(self): 75 | fieldnames = get_allowed_fieldnames_for_doctype("Has Role", parent_doctype="User") 76 | meta = frappe.get_meta("Has Role") 77 | self.assertCountEqual( 78 | fieldnames, 79 | [x.fieldname for x in meta.fields if x.fieldtype not in no_value_fields] 80 | + [x for x in default_fields if x != "doctype"] 81 | ) 82 | 83 | def test_on_child_doctype_with_no_parent_doctype(self): 84 | """ 85 | It should return all fields of the Child DocType with permlevel=0 86 | """ 87 | fieldnames = get_allowed_fieldnames_for_doctype("Has Role") 88 | meta = frappe.get_meta("Has Role") 89 | self.assertCountEqual( 90 | fieldnames, 91 | [x.fieldname for x in meta.fields 92 | if x.permlevel == 0 and x.fieldtype not in no_value_fields] + 93 | [x for x in default_fields if x != "doctype"] 94 | ) 95 | 96 | def _get_custom_user_meta(self): 97 | meta = frappe.get_meta("User") 98 | meta.permissions.append(dict( 99 | role="Guest", 100 | read=1, 101 | permlevel=0 102 | )) 103 | 104 | meta.get_field("full_name").permlevel = 1 105 | 106 | return meta 107 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | License: MIT -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | frappe 2 | graphql-core==3.2.1 3 | inflect==5.3.0 4 | graphql-sync-dataloaders==0.1.1 5 | mergedeep==1.3.4 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import ast 4 | from setuptools import setup, find_packages 5 | 6 | with open('requirements.txt') as f: 7 | install_requires = f.read().strip().split('\n') 8 | 9 | # get version from __version__ variable in frappe_graphql/__init__.py 10 | _version_re = re.compile(r'__version__\s+=\s+(.*)') 11 | 12 | with open('frappe_graphql/__init__.py', 'rb') as f: 13 | version = str(ast.literal_eval(_version_re.search( 14 | f.read().decode('utf-8')).group(1))) 15 | 16 | setup( 17 | name='frappe_graphql', 18 | version=version, 19 | description='GraphQL API Layer for Frappe Framework', 20 | author='Leam Technology Systems', 21 | author_email='info@leam.ae', 22 | packages=find_packages(), 23 | zip_safe=False, 24 | include_package_data=True, 25 | install_requires=install_requires 26 | ) 27 | --------------------------------------------------------------------------------