├── .gitignore ├── COPYING ├── README.mkd ├── parse_rest ├── __init__.py ├── cloudcode │ ├── cloud │ │ └── main.js │ └── config │ │ └── .gitignore ├── config.py ├── connection.py ├── core.py ├── datatypes.py ├── installation.py ├── query.py ├── role.py ├── test_relations.py ├── tests.py └── user.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store 3 | *.pyc 4 | *.pyo 5 | build/* 6 | settings_local.py 7 | dist 8 | MANIFEST 9 | global.json 10 | parse_rest.egg-info -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010-2016 Google, Inc. http://angularjs.org 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.mkd: -------------------------------------------------------------------------------- 1 | Note: As of May 13, 2016, this repository (`milesrichardson/ParsePy)` is the 2 | most up-to-date and active python client for the Parse API. It supports self-hosted 3 | `parse-server` via the REST API. Note that some features will not work with parse-server, 4 | if they are not supported by the REST API (e.g. push). 5 | 6 | See the section below, "using with self-hosted parse-server," for instructions. 7 | 8 | parse_rest 9 | ========== 10 | 11 | **parse_rest** is a Python client for the [Parse REST 12 | API](http://docs.parseplatform.org/rest/guide). It provides: 13 | 14 | - Python object mapping for Parse objects with methods to save, 15 | update, and delete objects, as well as an interface for querying 16 | stored objects. 17 | - Complex data types provided by Parse with no python equivalent 18 | - User authentication, account creation** (signup) and querying. 19 | - Cloud code integration 20 | - Installation querying 21 | - push 22 | - Roles/ACLs** 23 | - Image/File type support (done 1/14/17) 24 | 25 | 26 | ** for applications with access to the MASTER KEY, see details below. 27 | 28 | 29 | Installation 30 | ------------ 31 | 32 | The easiest way to install this package is by downloading or 33 | cloning this repository: 34 | 35 | pip install git+https://github.com/milesrichardson/ParsePy.git 36 | 37 | Note: The version on [PyPI](http://pypi.python.org/pypi) is not 38 | up-to-date. The code is still under lots of changes and the stability 39 | of the library API - though improving - is not guaranteed. Please 40 | file any issues that you may find if documentation/application. 41 | 42 | 43 | Using with self-hosted `parse-server` 44 | ------------- 45 | 46 | To use the library with self-hosted parse-server, set the environment variable 47 | `PARSE_API_ROOT` before importing the module. 48 | 49 | Example: 50 | 51 | ~~~~~ {python} 52 | import os 53 | os.environ["PARSE_API_ROOT"] = "http://your_server.com:1337/parse" 54 | 55 | # Everything else same as usual 56 | 57 | from parse_rest.datatypes import Function, Object, GeoPoint 58 | from parse_rest.connection import register 59 | from parse_rest.query import QueryResourceDoesNotExist 60 | from parse_rest.connection import ParseBatcher 61 | from parse_rest.core import ResourceRequestBadRequest, ParseError 62 | 63 | APPLICATION_ID = '...' 64 | REST_API_KEY = '...' 65 | MASTER_KEY = '...' 66 | 67 | register(APPLICATION_ID, REST_API_KEY, master_key=MASTER_KEY) 68 | ~~~~~ 69 | 70 | 71 | Testing 72 | ------- 73 | 74 | To run the tests, you need to: 75 | 76 | * create a `settings_local.py` file in your local directory with three 77 | variables that define a sample Parse application to use for testing: 78 | 79 | ~~~~~ {python} 80 | APPLICATION_ID = "APPLICATION_ID_HERE" 81 | REST_API_KEY = "REST_API_KEY_HERE" 82 | MASTER_KEY = "MASTER_KEY_HERE" 83 | ~~~~~ 84 | 85 | Note Do **not** give the keys of an existing application with data you want to 86 | keep: create a new one instead. The test suite will erase any existing CloudCode 87 | in the app and may accidentally replace or change existing objects. 88 | 89 | * install the [Parse CloudCode tool](http://docs.parseplatform.org/cloudcode/guide/) 90 | 91 | You can then test the installation by running the following command: 92 | 93 | # test all 94 | python -m unittest parse_rest.tests 95 | 96 | # or test individually 97 | python -m unittest parse_rest.tests.TestObject.testCanCreateNewObject 98 | 99 | Usage 100 | ----------- 101 | 102 | Before the first interaction with the Parse server, you need to 103 | register your access credentials. You can do so by calling 104 | `parse_rest.connection.register`. 105 | 106 | Before getting to code, a word of caution. You need to consider how your application is 107 | meant to be deployed. Parse identifies your application through 108 | different keys (available from your Parse dashboard) that are used in 109 | every request done to their servers. 110 | 111 | If your application is supposed to be distributed to third parties 112 | (such as a desktop program to be installed), you SHOULD NOT put the 113 | master key in your code. If your application is meant to be running in 114 | systems that you fully control (e.g, a web app that needs to integrate 115 | with Parse to provide functionality to your client), you may also add 116 | your *master key*. 117 | 118 | ~~~~~ {python} 119 | from parse_rest.connection import register 120 | register(, [, master_key=None]) 121 | ~~~~~ 122 | 123 | Once your application calls `register`, you will be able to read, write 124 | and query for data at Parse. 125 | 126 | 127 | Data types 128 | ---------- 129 | 130 | Parse allows us to get data in different base types that have a direct 131 | python equivalent (strings, integers, floats, dicts, lists) as well as 132 | some more complex ones (e.g.:`File`, `Image`, `Date`). It also allows 133 | us to define objects with schema-free structure, and save them, as 134 | well to query them later by their attributes. `parse_rest` is 135 | handy as a way to serialize/deserialize these objects transparently. 136 | 137 | The Object type 138 | --------------- 139 | 140 | 141 | In theory, you are able to simply instantiate a `Object` and do 142 | everything that you want with it, save it on Parse, retrieve it later, 143 | etc. 144 | 145 | ~~~~~ {python} 146 | from parse_rest.datatypes import Object 147 | 148 | first_object = Object() 149 | ~~~~~ 150 | 151 | In practice, you will probably want different classes for your 152 | application to allow for a better organization in your own code. 153 | So, let's say you want to make an online game, and you want to save 154 | the scoreboard on Parse. For that, you decide to define a class called 155 | `GameScore`. All you need to do to create such a class is to define a 156 | Python class that inherts from `parse_rest.datatypes.Object`: 157 | 158 | ~~~~~ {python} 159 | from parse_rest.datatypes import Object 160 | 161 | class GameScore(Object): 162 | pass 163 | ~~~~~ 164 | 165 | You can also create an Object subclass by string name, with the `Object.factory` 166 | method: 167 | 168 | ~~~~~ {python} 169 | from parse_rest.datatypes import Object 170 | 171 | myClassName = "GameScore" 172 | myClass = Object.factory(myClassName) 173 | 174 | print myClass 175 | # 176 | print myClass.__name__ 177 | # GameScore 178 | ~~~~~ 179 | 180 | You can then instantiate your new class with some parameters: 181 | 182 | ~~~~~ {python} 183 | gameScore = GameScore(score=1337, player_name='John Doe', cheat_mode=False) 184 | ~~~~~ 185 | 186 | You can change or set new parameters afterwards: 187 | 188 | ~~~~ {python} 189 | gameScore.cheat_mode = True 190 | gameScore.level = 20 191 | ~~~~ 192 | 193 | To save our new object, just call the save() method: 194 | 195 | ~~~~~ {python} 196 | gameScore.save() 197 | ~~~~~ 198 | 199 | If we want to make an update, just call save() again after modifying 200 | an attribute to send the changes to the server: 201 | 202 | ~~~~~ {python} 203 | gameScore.score = 2061 204 | gameScore.save() 205 | ~~~~~ 206 | 207 | You can also increment the score in a single API query: 208 | 209 | ~~~~~ {python} 210 | gameScore.increment("score") 211 | ~~~~~ 212 | 213 | Now that we've done all that work creating our first Parse object, let's delete it: 214 | 215 | ~~~~~ {python} 216 | gameScore.delete() 217 | ~~~~~ 218 | 219 | That's it! You're ready to start saving data on Parse. 220 | 221 | Object Metadata 222 | --------------- 223 | 224 | The attributes objectId, createdAt, and updatedAt show metadata about 225 | a _Object_ that cannot be modified through the API: 226 | 227 | ~~~~~ {python} 228 | gameScore.objectId 229 | # 'xxwXx9eOec' 230 | gameScore.createdAt 231 | # datetime.datetime(2011, 9, 16, 21, 51, 36, 784000) 232 | gameScore.updatedAt 233 | # datetime.datetime(2011, 9, 118, 14, 18, 23, 152000) 234 | ~~~~~ 235 | 236 | Additional Datatypes 237 | -------------------- 238 | 239 | We've mentioned that Parse supports more complex types, most of these 240 | types are also supported on Python (dates, files). So these types can 241 | be converted transparently when you use them. For the types that Parse 242 | provided and Python does not support natively, `parse_rest` provides 243 | the appropiates classes to work with them. One such example is 244 | `GeoPoint`, where you store latitude and longitude 245 | 246 | ~~~~~ {python} 247 | from parse_rest.datatypes import Object, GeoPoint 248 | 249 | class Restaurant(Object): 250 | pass 251 | 252 | restaurant = Restaurant(name="Los Pollos Hermanos") 253 | # coordinates as floats. 254 | restaurant.location = GeoPoint(latitude=12.0, longitude=-34.45) 255 | restaurant.save() 256 | ~~~~~ 257 | 258 | We can store a reference to another Object by assigning it to an attribute: 259 | 260 | ~~~~~ {python} 261 | from parse_rest.datatypes import Object 262 | 263 | class CollectedItem(Object): 264 | pass 265 | 266 | collectedItem = CollectedItem(type="Sword", isAwesome=True) 267 | collectedItem.save() # we have to save it before it can be referenced 268 | 269 | gameScore.item = collectedItem 270 | ~~~~~ 271 | 272 | 273 | File Support 274 | --------------- 275 | 276 | You can upload files to parse (assuming your `parse-server` instance supports it). 277 | This has been tested with the default GridStore adapter. 278 | 279 | Example: 280 | 281 | ~~~~~ {python} 282 | from parse_rest.datatypes import Object, File 283 | 284 | class GameScore(Object): 285 | pass 286 | 287 | # 1. Upload file 288 | 289 | with open('/path/to/screenshot.png', 'rb') as fh: 290 | rawdata = fh.read() 291 | 292 | screenshotFile = File('arbitraryNameOfFile', rawdata, 'image/png') 293 | screenshotFile.save() 294 | 295 | print screenshotFile.url 296 | 297 | # 2. Attach file to gamescore object and save 298 | gs = GameScore.Query.get(objectId='xxxxxxx') 299 | gs.screenshot = screenshotFile 300 | gs.save() 301 | 302 | print gs.file.url 303 | ~~~~~ 304 | 305 | 306 | Batch Operations 307 | ---------------- 308 | 309 | For the sake of efficiency, Parse also supports creating, updating or deleting objects in batches using a single query, which saves on network round trips. You can perform such batch operations using the `connection.ParseBatcher` object: 310 | 311 | ~~~~~ {python} 312 | from parse_rest.connection import ParseBatcher 313 | 314 | score1 = GameScore(score=1337, player_name='John Doe', cheat_mode=False) 315 | score2 = GameScore(score=1400, player_name='Jane Doe', cheat_mode=False) 316 | score3 = GameScore(score=2000, player_name='Jack Doe', cheat_mode=True) 317 | scores = [score1, score2, score3] 318 | 319 | batcher = ParseBatcher() 320 | batcher.batch_save(scores) 321 | batcher.batch_delete(scores) 322 | ~~~~~ 323 | 324 | You can also mix `save` and `delete` operations in the same query as follows (note the absence of parentheses after each `save` or `delete`): 325 | 326 | ~~~~~ {python} 327 | batcher.batch([score1.save, score2.save, score3.delete]) 328 | ~~~~~ 329 | 330 | If an error occurs during one or multiple of the operations, it will not affect 331 | the execution of the remaining operations. Instead, the `batcher.batch_save` or 332 | `batcher.batch_delete` or `batcher.batch` will raise a `ParseBatchError` 333 | (child of `ParseError`) exception with `.message` set to a *list* of the errors 334 | encountered. For example: 335 | 336 | ~~~~~ {python} 337 | # Batch save a list of two objects: 338 | # dupe_object is a duplicate violating a unique key constraint 339 | # dupe_object2 is a duplicate violating a unique key constraint 340 | # new_object is a new object satisfying the unique key constraint 341 | # 342 | # dupe_object and dupe_object2 will fail to save, and new_object will save successfully 343 | 344 | dupe_object = list(MyClass.Query.all().limit(2))[0] 345 | dupe_object2 = list(MyClass.Query.all().limit(2))[1] 346 | new_object = MyClass(some_column=11111) 347 | objects = [dupe_object, dupe_object2, new_object] 348 | 349 | batcher = ParseBatcher() 350 | batcher.batch_save(objects) 351 | ~~~~~ 352 | 353 | will raise an exception: 354 | 355 | ~~~~~ {python} 356 | Traceback (most recent call last): 357 | File "", line 1, in 358 | File "/Users/miles/ParsePy/parse_rest/connection.py", line 199, in batch_save 359 | self.batch(o.save for o in objects) 360 | File "/Users/miles/ParsePy/parse_rest/connection.py", line 195, in batch 361 | raise core.ParseBatchError(batched_errors) 362 | 363 | ParseBatchError: [{u'code': 11000, u'error': u'E11000 duplicate key error index: myapp.MyClass.$my_column_1 dup key: { : 555555 }'}, {u'code': 11000, u'error': u'E11000 duplicate key error index: myapp.MyClass.$my_column_1 dup key: { : 44444 }'}] 364 | ~~~~~ 365 | 366 | And `CRUCIALLY`, the objectId field of the NON-duplicate object will be correctly set: 367 | 368 | ~~~~~ {python} 369 | >>> #batch_save as above... 370 | >>> print objects 371 | [, , ] 372 | ~~~~~ 373 | 374 | Therefore, one way to tell which objects saved successfully after a batch save operation 375 | is to check which objects have `objectId` set. 376 | 377 | Querying 378 | -------- 379 | 380 | Any class inheriting from `parse_rest.Object` has a `Query` 381 | object. With it, you can perform queries that return a set of objects 382 | or that will return a object directly. 383 | 384 | 385 | ### Retrieving a single object 386 | 387 | To retrieve an object with a Parse class of `GameScore` and an 388 | `objectId` of `xxwXx9eOec`, run: 389 | 390 | ~~~~~ {python} 391 | gameScore = GameScore.Query.get(objectId="xxwXx9eOec") 392 | ~~~~~ 393 | 394 | ### Working with Querysets 395 | 396 | To query for sets of objects, we work with the concept of 397 | `Queryset`s. If you are familiar with Django you will be right at home 398 | \- but be aware that is not a complete implementation of their 399 | Queryset or Database backend. 400 | 401 | The Query object contains a method called `all()`, which will return a 402 | basic (unfiltered) Queryset. It will represent the set of all objects 403 | of the class you are querying. 404 | 405 | ~~~~~ {python} 406 | all_scores = GameScore.Query.all() 407 | ~~~~~ 408 | 409 | Querysets are _lazily evaluated_, meaning that it will only actually 410 | make a request to Parse when you either call a method that needs to 411 | operate on the data, or when you iterate on the Queryset. 412 | 413 | #### Filtering 414 | 415 | Like Django, Querysets can have constraints added by appending the name of the filter operator to name of the attribute: 416 | 417 | ~~~~~ {python} 418 | high_scores = GameScore.Query.filter(score__gte=1000) 419 | ~~~~~ 420 | 421 | You can similarly perform queries on GeoPoint objects by using the `nearSphere` operator: 422 | 423 | ~~~~~ {python} 424 | my_loc = GeoPoint(latitude=12.0, longitude=-34.55) 425 | nearby_restaurants = Restaurant.Query.filter(location__nearSphere=my_loc) 426 | ~~~~~ 427 | 428 | You can see the [full list of constraint operators defined by 429 | Parse](http://docs.parseplatform.org/rest/guide/#query-constraints) 430 | 431 | 432 | #### Sorting/Ordering 433 | 434 | Querysets can also be ordered. Just define the name of the attribute 435 | that you want to use to sort. Appending a "-" in front of the name 436 | will sort the set in descending order. 437 | 438 | ~~~~~ {python} 439 | low_to_high_score_board = GameScore.Query.all().order_by("score") 440 | high_to_low_score_board = GameScore.Query.all().order_by("-score") # or order_by("score", descending=True) 441 | ~~~~~ 442 | 443 | #### Limit/Skip 444 | 445 | If you don't want the whole set, you can apply the 446 | limit and skip function. Let's say you have a have classes 447 | representing a blog, and you want to implement basic pagination: 448 | 449 | ~~~~~ {python} 450 | posts = Post.Query.all().order_by("-publication_date") 451 | page_one = posts.limit(10) # Will return the most 10 recent posts. 452 | page_two = posts.skip(10).limit(10) # Will return posts 11-20 453 | ~~~~~ 454 | 455 | #### Related objects 456 | 457 | You can specify "join" attributes to get related object with single query. 458 | 459 | ~~~~~ {python} 460 | posts = Post.Query.all().select_related("author", "editor") 461 | ~~~~~ 462 | 463 | #### Composability/Chaining of Querysets 464 | 465 | The example above can show the most powerful aspect of Querysets, that 466 | is the ability to make complex querying and filtering by chaining calls: 467 | 468 | Most importantly, Querysets can be chained together. This allows you 469 | to make more complex queries: 470 | 471 | ~~~~~ {python} 472 | posts_by_joe = Post.Query.all().filter(author='Joe').order_by("view_count") 473 | popular_posts = posts_by_joe.gte(view_count=200) 474 | ~~~~~ 475 | 476 | #### Iterating on Querysets 477 | 478 | After all the querying/filtering/sorting, you will probably want to do 479 | something with the results. Querysets can be iterated on: 480 | 481 | ~~~~~ {python} 482 | posts_by_joe = Post.Query.all().filter(author='Joe').order_by('view_count') 483 | for post in posts_by_joe: 484 | print post.title, post.publication_date, post.text 485 | ~~~~~ 486 | 487 | **TODO**: Slicing of Querysets 488 | 489 | 490 | Relations 491 | --------- 492 | 493 | A Relation is field that contains references to multiple objects. 494 | You can query this subset of objects. 495 | 496 | (Note that Parse's relations are "one sided" and don't involve a join table. [See the docs.](http://docs.parseplatform.org/js/guide/#many-to-many)) 497 | 498 | For example, if we have Game and GameScore classes, and one game 499 | can have multiple GameScores, you can use relations to associate 500 | those GameScores with a Game. 501 | 502 | ~~~~~ {python} 503 | game = Game(name="3-way Battle") 504 | game.save() 505 | score1 = GameScore(player_name='Ronald', score=100) 506 | score2 = GameScore(player_name='Rebecca', score=140) 507 | score3 = GameScore(player_name='Sara', score=190) 508 | relation = game.relation('scores') 509 | relation.add([score1, score2, score3]) 510 | ~~~~~ 511 | 512 | A Game gets added, three GameScores get added, and three relations 513 | are created associating the GameScores with the Game. 514 | 515 | To retreive the related scores for a game, you use query() to get a 516 | Queryset for the relation. 517 | 518 | ~~~~~ {python} 519 | scores = relation.query() 520 | for gamescore in scores: 521 | print gamescore.player_name, gamescore.score 522 | ~~~~~ 523 | 524 | The query is limited to the objects previously added to the 525 | relation. 526 | 527 | ~~~~~ {python} 528 | scores = relation.query().order_by('score', descending=True) 529 | for gamescore in scores: 530 | print gamescore.player_name, gamescore.score 531 | ~~~~~ 532 | 533 | To remove objects from a relation, you use remove(). This example 534 | removes all the related objects. 535 | 536 | ~~~~~ {python} 537 | scores = relation.query() 538 | for gamescore in scores: 539 | relation.remove(gamescore) 540 | ~~~~~ 541 | 542 | 543 | Users 544 | ----- 545 | 546 | You can sign up, log in, modify or delete users as well, using the `parse_rest.user.User` class. You sign a user up as follows: 547 | 548 | ~~~~~ {python} 549 | from parse_rest.user import User 550 | 551 | u = User.signup("dhelmet", "12345", phone="555-555-5555") 552 | ~~~~~ 553 | 554 | or log in an existing user with 555 | 556 | ~~~~~ {python} 557 | u = User.login("dhelmet", "12345") 558 | ~~~~~ 559 | 560 | You can also request a password reset for a specific user with 561 | 562 | ~~~~~ {python} 563 | User.request_password_reset(email="dhelmet@gmail.com") 564 | ~~~~~ 565 | 566 | If you'd like to log in a user with Facebook or Twitter, and have already obtained an access token (including a user ID and expiration date) to do so, you can log in like this: 567 | 568 | ~~~~ {python} 569 | authData = {"facebook": {"id": fbID, "access_token": access_token, 570 | "expiration_date": expiration_date}} 571 | u = User.login_auth(authData) 572 | ~~~~ 573 | 574 | Once a `User` has been logged in, it saves its session so that it can be edited or deleted: 575 | 576 | ~~~~~ {python} 577 | u.highscore = 300 578 | u.save() 579 | u.delete() 580 | ~~~~~ 581 | 582 | To get the current user from a Parse session: 583 | 584 | ~~~~~ {python} 585 | from parse_rest.connection import SessionToken, register 586 | 587 | # Acquire a valid parse session somewhere 588 | # Example: token = request.session.get('session_token') 589 | 590 | # Method 1: Using a `with` statement 591 | # Do this to isolate use of session token in this block only 592 | with SessionToken(token): 593 | me = User.current_user() 594 | 595 | # Method 2: register your parse connection with `session_token` parameter 596 | # Do this to use the session token for all subsequent queries 597 | register(PARSE_APPID, PARSE_APIKEY, session_token=token) 598 | me = User.current_user() 599 | ~~~~~ 600 | 601 | 602 | Push 603 | --------------- 604 | 605 | You can also send notifications to your users using [Parse's Push functionality](http://docs.parseplatform.org/rest/guide/#push-notifications), through the Push object: 606 | 607 | ~~~~~ {python} 608 | from parse_rest.installation import Push 609 | 610 | Push.message("The Giants won against the Mets 2-3.", 611 | channels=["Giants", "Mets"]) 612 | ~~~~~ 613 | 614 | This will push a message to all users subscribed to the "Giants" and "Mets" channels. Your alert can be restricted based on [Advanced Targeting](http://docs.parseplatform.org/rest/guide/#sending-pushes-to-queries) by specifying the `where` argument: 615 | 616 | ~~~~~ {python} 617 | Push.message("Willie Hayes injured by own pop fly.", 618 | channels=["Giants"], where={"injuryReports": True}) 619 | 620 | Push.message("Giants scored against the A's! It's now 2-2.", 621 | channels=["Giants"], where={"scores": True}) 622 | ~~~~~ 623 | 624 | If you wish to include more than a simple message in your notification, such as incrementing the app badge in iOS or adding a title in Android, use the `alert` method and pass the actions in a dictionary: 625 | 626 | ~~~~~ {python} 627 | Push.alert({"alert": "The Mets scored! The game is now tied 1-1.", 628 | "badge": "Increment", "title": "Mets Score"}, channels=["Mets"], 629 | where={"scores": True}) 630 | ~~~~~ 631 | 632 | 633 | Cloud Functions 634 | --------------- 635 | 636 | Parse offers [CloudCode](http://docs.parseplatform.org/rest/guide/#cloud-code), which has the ability to upload JavaScript functions that will be run on the server. You can use the `parse_rest` client to call those functions. 637 | 638 | The CloudCode guide describes how to upload a function to the server. Let's say you upload the following `main.js` script: 639 | 640 | ~~~~~ {javascript} 641 | Parse.Cloud.define("hello", function(request, response) { 642 | response.success("Hello world!"); 643 | }); 644 | 645 | 646 | Parse.Cloud.define("averageStars", function(request, response) { 647 | var query = new Parse.Query("Review"); 648 | query.equalTo("movie", request.params.movie); 649 | query.find({ 650 | success: function(results) { 651 | var sum = 0; 652 | for (var i = 0; i < results.length; ++i) { 653 | sum += results[i].get("stars"); 654 | } 655 | response.success(sum / results.length); 656 | }, 657 | error: function() { 658 | response.error("movie lookup failed"); 659 | } 660 | }); 661 | }); 662 | ~~~~~ 663 | 664 | Then you can call either of these functions using the `parse_rest.datatypes.Function` class: 665 | 666 | ~~~~~ {python} 667 | from parse_rest.datatypes import Function 668 | 669 | hello_func = Function("hello") 670 | hello_func() 671 | {u'result': u'Hello world!'} 672 | star_func = Function("averageStars") 673 | star_func(movie="The Matrix") 674 | {u'result': 4.5} 675 | ~~~~~ 676 | 677 | 678 | ACLs 679 | --------------- 680 | The ACL for an object can be updated using the `parse_rest.datatypes.ACL` class. This class provides three methods for setting an ACL: set_user, set_role, and set_default. For example, using the User and gameScore examples from above: 681 | ~~~~~ {python} 682 | from parse_rest.datatypes import ACL 683 | from parse_rest.user import User 684 | 685 | u = User.login('dhelmet', '12345') 686 | 687 | gameScore.ACL.set_user(u, read=True, write=True) 688 | # allows user 'dhelmet' to read and write to gameScore 689 | gameScore.ACL.set_default(read=True) 690 | # allows public to read but not write to gameScore 691 | gameScore.ACL.set_role('moderators', read=True, write=True) 692 | # allows role 'moderators' to read and write to gameScore. Can alternatively pass the role object instead of the 693 | # role name. See below for more info on Roles. 694 | gameScore.save() 695 | ~~~~~ 696 | 697 | 698 | Roles 699 | --------------- 700 | You can create, update or delete roles as well, using the `parse_rest.role.Role` class. Creating a role requires you to pass a name and an ACL to Role. 701 | ~~~~~ {python} 702 | from parse_rest.role import Role 703 | from parse_rest.datatypes import ACL 704 | 705 | admin_role = Role(name='moderators') 706 | admin_role.ACL.set_default(read=True) 707 | admin_role.save() 708 | ~~~~~ 709 | 710 | This, for example, creates a role with the name 'moderators', with an ACL that allows the public to read but not write to this role object. 711 | 712 | 713 | Session Tokens 714 | --------------- 715 | When querying or updating an object protected by an ACL, parse.com requires the session token of the user with read and write privileges, respectively. You can pass the session token to such queries and updates by using the `parse_rest.connection.SessionToken` class. 716 | 717 | ~~~~~ {python} 718 | from parse_rest.connection import SessionToken 719 | from parse_rest.user import User 720 | 721 | u = User.login('dhelmet', '12345') 722 | token = u.sessionToken 723 | 724 | with SessionToken(token): 725 | collectedItem = CollectedItem.Query.get(type="Sword") # Get a collected item, Sword, that is protected by ACL 726 | print collectedItem 727 | 728 | u.logout() 729 | ~~~~~ 730 | 731 | Assuming the CollectedItem 'Sword' is read-protected from the public by an ACL and is readable only by the user, SessionToken allows the user to bypass the ACL and get the 'Sword' item. 732 | 733 | Elevating Access to Master 734 | -------------------------- 735 | Sometimes it is useful to only allow privileged use of the master key for specific uses. 736 | 737 | ~~~~~ {python} 738 | from parse_rest.connection import MasterKey 739 | 740 | with MasterKey('master key'): 741 | # do privileged calls 742 | ~~~~~ 743 | -------------------------------------------------------------------------------- /parse_rest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesrichardson/ParsePy/3785055a1d4c5171b0565121a9af5133963d3cc8/parse_rest/__init__.py -------------------------------------------------------------------------------- /parse_rest/cloudcode/cloud/main.js: -------------------------------------------------------------------------------- 1 | 2 | // Use Parse.Cloud.define to define as many cloud functions as you want. 3 | // For example: 4 | Parse.Cloud.define("hello", function(request, response) { 5 | response.success("Hello world!"); 6 | }); 7 | 8 | 9 | Parse.Cloud.define("averageStars", function(request, response) { 10 | var query = new Parse.Query("Review"); 11 | query.equalTo("movie", request.params.movie); 12 | query.find({ 13 | success: function(results) { 14 | var sum = 0; 15 | for (var i = 0; i < results.length; ++i) { 16 | sum += results[i].get("stars"); 17 | } 18 | response.success(sum / results.length); 19 | }, 20 | error: function() { 21 | response.error("movie lookup failed"); 22 | } 23 | }); 24 | }); 25 | 26 | Parse.Job.define("calculationJob", function(request, response) { 27 | var query = new Parse.Query("Review"); 28 | query.equalTo("movie", request.params.movie); 29 | query.find({ 30 | success: function(results) { 31 | var sum = 0; 32 | for (var i = 0; i < results.length; ++i) { 33 | sum += results[i].get("stars"); 34 | } 35 | var avg = sum / results.length; 36 | response.success("job finished. avg is " + avg); 37 | }, 38 | error: function() { 39 | response.error("movie lookup failed"); 40 | } 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /parse_rest/cloudcode/config/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /parse_rest/config.py: -------------------------------------------------------------------------------- 1 | from parse_rest.connection import API_ROOT 2 | from parse_rest.datatypes import ParseResource 3 | 4 | 5 | class Config(ParseResource): 6 | ENDPOINT_ROOT = '/'.join([API_ROOT, 'config']) 7 | 8 | @classmethod 9 | def get(cls): 10 | return cls.GET('').get('params') 11 | 12 | -------------------------------------------------------------------------------- /parse_rest/connection.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | from six.moves.urllib.request import Request, urlopen 15 | from six.moves.urllib.error import HTTPError 16 | from six.moves.urllib.parse import urlencode, urlparse 17 | 18 | import json 19 | 20 | from parse_rest import core 21 | 22 | import os 23 | 24 | API_ROOT = os.environ.get('PARSE_API_ROOT') or 'https://api.parse.com/1' 25 | 26 | ACCESS_KEYS = {} 27 | 28 | 29 | # Connection can sometimes hang forever on SSL handshake 30 | CONNECTION_TIMEOUT = 60 31 | 32 | 33 | def register(app_id, rest_key, **kw): 34 | global ACCESS_KEYS 35 | ACCESS_KEYS = { 36 | 'app_id': app_id, 37 | 'rest_key': rest_key 38 | } 39 | ACCESS_KEYS.update(**kw) 40 | 41 | 42 | class SessionToken: 43 | def __init__(self, token): 44 | global ACCESS_KEYS 45 | self.token = token 46 | 47 | def __enter__(self): 48 | ACCESS_KEYS.update({'session_token': self.token}) 49 | 50 | def __exit__(self, type, value, traceback): 51 | del ACCESS_KEYS['session_token'] 52 | 53 | 54 | class MasterKey: 55 | def __init__(self, master_key): 56 | global ACCESS_KEYS 57 | self.master_key = master_key 58 | 59 | def __enter__(self): 60 | return ACCESS_KEYS.update({'master_key': self.master_key}) 61 | 62 | def __exit__(self, type, value, traceback): 63 | del ACCESS_KEYS['master_key'] 64 | 65 | 66 | def master_key_required(func): 67 | '''decorator describing methods that require the master key''' 68 | def ret(obj, *args, **kw): 69 | conn = ACCESS_KEYS 70 | if not (conn and conn.get('master_key')): 71 | message = '%s requires the master key' % func.__name__ 72 | raise core.ParseError(message) 73 | func(obj, *args, **kw) 74 | return ret 75 | 76 | # Using this as "default=" argument solve the problem with Datetime object not being JSON serializable 77 | def date_handler(obj): 78 | return obj.isoformat() if hasattr(obj, 'isoformat') else obj 79 | 80 | 81 | class ParseBase(object): 82 | ENDPOINT_ROOT = API_ROOT 83 | 84 | @classmethod 85 | def execute(cls, uri, http_verb, extra_headers=None, batch=False, _body=None, **kw): 86 | """ 87 | if batch == False, execute a command with the given parameters and 88 | return the response JSON. 89 | If batch == True, return the dictionary that would be used in a batch 90 | command. 91 | """ 92 | if batch: 93 | urlsplitter = urlparse(API_ROOT).netloc 94 | ret = {"method": http_verb, "path": uri.split(urlsplitter, 1)[1]} 95 | if kw: 96 | ret["body"] = kw 97 | return ret 98 | 99 | if not ('app_id' in ACCESS_KEYS and 'rest_key' in ACCESS_KEYS): 100 | raise core.ParseError('Missing connection credentials') 101 | 102 | app_id = ACCESS_KEYS.get('app_id') 103 | rest_key = ACCESS_KEYS.get('rest_key') 104 | master_key = ACCESS_KEYS.get('master_key') 105 | 106 | url = uri if uri.startswith(API_ROOT) else cls.ENDPOINT_ROOT + uri 107 | if _body is None: 108 | data = kw and json.dumps(kw, default=date_handler) or "{}" 109 | else: 110 | data = _body 111 | if http_verb == 'GET' and data: 112 | url += '?%s' % urlencode(kw) 113 | data = None 114 | else: 115 | if cls.__name__ == 'File': 116 | data = data 117 | else: 118 | data = data.encode('utf-8') 119 | 120 | headers = { 121 | 'Content-type': 'application/json', 122 | 'X-Parse-Application-Id': app_id, 123 | 'X-Parse-REST-API-Key': rest_key 124 | } 125 | headers.update(extra_headers or {}) 126 | 127 | if cls.__name__ == 'File': 128 | request = Request(url.encode('utf-8'), data, headers) 129 | else: 130 | request = Request(url, data, headers) 131 | 132 | if ACCESS_KEYS.get('session_token'): 133 | request.add_header('X-Parse-Session-Token', ACCESS_KEYS.get('session_token')) 134 | elif master_key: 135 | request.add_header('X-Parse-Master-Key', master_key) 136 | 137 | request.get_method = lambda: http_verb 138 | 139 | try: 140 | response = urlopen(request, timeout=CONNECTION_TIMEOUT) 141 | except HTTPError as e: 142 | exc = { 143 | 400: core.ResourceRequestBadRequest, 144 | 401: core.ResourceRequestLoginRequired, 145 | 403: core.ResourceRequestForbidden, 146 | 404: core.ResourceRequestNotFound 147 | }.get(e.code, core.ParseError) 148 | raise exc(e.read()) 149 | 150 | return json.loads(response.read().decode('utf-8')) 151 | 152 | @classmethod 153 | def GET(cls, uri, **kw): 154 | return cls.execute(uri, 'GET', **kw) 155 | 156 | @classmethod 157 | def POST(cls, uri, **kw): 158 | return cls.execute(uri, 'POST', **kw) 159 | 160 | @classmethod 161 | def PUT(cls, uri, **kw): 162 | return cls.execute(uri, 'PUT', **kw) 163 | 164 | @classmethod 165 | def DELETE(cls, uri, **kw): 166 | return cls.execute(uri, 'DELETE', **kw) 167 | 168 | @classmethod 169 | def drop(cls): 170 | return cls.POST("%s/schemas/%s" % (API_ROOT, cls.__name__), 171 | _method="DELETE", _ClientVersion="browser") 172 | 173 | 174 | class ParseBatcher(ParseBase): 175 | """Batch together create, update or delete operations""" 176 | ENDPOINT_ROOT = '/'.join((API_ROOT, 'batch')) 177 | 178 | def batch(self, methods): 179 | """ 180 | Given a list of create, update or delete methods to call, call all 181 | of them in a single batch operation. 182 | """ 183 | methods = list(methods) # methods can be iterator 184 | if not methods: 185 | #accepts also empty list (or generator) - it allows call batch directly with query result (eventually empty) 186 | return 187 | queries, callbacks = list(zip(*[m(batch=True) for m in methods])) 188 | # perform all the operations in one batch 189 | responses = self.execute("", "POST", requests=queries) 190 | # perform the callbacks with the response data (updating the existing 191 | # objets, etc) 192 | 193 | batched_errors = [] 194 | for callback, response in zip(callbacks, responses): 195 | if "success" in response: 196 | callback(response["success"]) 197 | else: 198 | batched_errors.append(response["error"]) 199 | 200 | if batched_errors: 201 | raise core.ParseBatchError(batched_errors) 202 | 203 | def batch_save(self, objects): 204 | """save a list of objects in one operation""" 205 | self.batch(o.save for o in objects) 206 | 207 | def batch_delete(self, objects): 208 | """delete a list of objects in one operation""" 209 | self.batch(o.delete for o in objects) 210 | -------------------------------------------------------------------------------- /parse_rest/core.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | 15 | class ParseError(Exception): 16 | '''Base exceptions from requests made to Parse''' 17 | pass 18 | 19 | class ParseBatchError(Exception): 20 | ''' Error in batching operation... should take a list. ''' 21 | pass 22 | 23 | class ResourceRequestBadRequest(ParseError): 24 | '''Request returns a 400''' 25 | pass 26 | 27 | 28 | class ResourceRequestLoginRequired(ParseError): 29 | '''Request returns a 401''' 30 | pass 31 | 32 | 33 | class ResourceRequestForbidden(ParseError): 34 | '''Request returns a 403''' 35 | pass 36 | 37 | 38 | class ResourceRequestNotFound(ParseError): 39 | '''Request returns a 404''' 40 | pass 41 | -------------------------------------------------------------------------------- /parse_rest/datatypes.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | from __future__ import unicode_literals 14 | 15 | import base64 16 | import datetime 17 | import mimetypes 18 | import six 19 | 20 | from parse_rest.connection import API_ROOT, ParseBase 21 | from parse_rest.query import QueryManager 22 | from parse_rest.core import ParseError 23 | 24 | 25 | def complex_type(name=None): 26 | '''Decorator for registering complex types''' 27 | def wrapped(cls): 28 | ParseType.type_mapping[name or cls.__name__] = cls 29 | return cls 30 | return wrapped 31 | 32 | 33 | class ParseType(object): 34 | type_mapping = {} 35 | 36 | @staticmethod 37 | def convert_from_parse(parse_key, parse_data): 38 | if isinstance(parse_data, list): 39 | return [ParseType.convert_from_parse(parse_key, item) for item in parse_data] 40 | 41 | parse_type = None 42 | if isinstance(parse_data, dict): 43 | if '__type' in parse_data: 44 | parse_type = parse_data.pop('__type') 45 | elif parse_key == 'ACL': 46 | parse_type = 'ACL' 47 | 48 | # if its not a parse type -- simply return it. This means it wasn't a "special class" 49 | if not parse_type: 50 | return parse_data 51 | 52 | native = ParseType.type_mapping.get(parse_type) 53 | return native.from_native(**parse_data) if native else parse_data 54 | 55 | @staticmethod 56 | def convert_to_parse(python_object, as_pointer=False): 57 | is_object = isinstance(python_object, ParseResource) #User is derived from ParseResouce not Object, check against ParseResource 58 | 59 | if is_object and not as_pointer: 60 | return dict([(k, ParseType.convert_to_parse(v, as_pointer=True)) 61 | for k, v in python_object._editable_attrs.items() 62 | ]) 63 | 64 | python_type = ParseResource if is_object else type(python_object) 65 | 66 | # classes that need to be cast to a different type before serialization 67 | transformation_map = { 68 | datetime.datetime: Date, 69 | ParseResource: Pointer 70 | } 71 | 72 | if (hasattr(python_object, '__iter__') and 73 | not isinstance(python_object, (six.string_types[0], ParseType))): 74 | # It's an iterable? Repeat this whole process on each object 75 | if isinstance(python_object, dict): 76 | for key, value in python_object.items(): 77 | python_object[key]=ParseType.convert_to_parse(value, as_pointer=as_pointer) 78 | return python_object 79 | else: 80 | return [ParseType.convert_to_parse(o, as_pointer=as_pointer) 81 | for o in python_object] 82 | 83 | if python_type in transformation_map: 84 | klass = transformation_map.get(python_type) 85 | return klass(python_object)._to_native() 86 | 87 | if isinstance(python_object, ParseType): 88 | return python_object._to_native() 89 | 90 | return python_object 91 | 92 | @classmethod 93 | def from_native(cls, **kw): 94 | return cls(**kw) 95 | 96 | def _to_native(self): 97 | raise NotImplementedError("_to_native must be overridden") 98 | 99 | 100 | @complex_type('Pointer') 101 | class Pointer(ParseType): 102 | 103 | @classmethod 104 | def from_native(cls, **kw): 105 | # create object with only objectId and unloaded flag. it is automatically loaded when any other field is accessed 106 | klass = Object.factory(kw.get('className')) 107 | return klass(objectId=kw.get('objectId'), _is_loaded=False) 108 | 109 | 110 | def __init__(self, obj): 111 | self._object = obj 112 | 113 | def _to_native(self): 114 | return { 115 | '__type': 'Pointer', 116 | 'className': self._object.className, 117 | 'objectId': self._object.objectId 118 | } 119 | 120 | 121 | @complex_type('Object') 122 | class EmbeddedObject(ParseType): 123 | @classmethod 124 | def from_native(cls, **kw): 125 | klass = Object.factory(kw.pop('className')) 126 | return klass(**kw) 127 | 128 | 129 | @complex_type('Relation') 130 | class Relation(ParseType): 131 | @classmethod 132 | def from_native(cls, **kw): 133 | return cls(**kw) 134 | 135 | def with_parent(self, **kw): 136 | """The parent calls this if the Relation already exists.""" 137 | if 'parentObject' in kw: 138 | self.parentObject = kw['parentObject'] 139 | self.key = kw['key'] 140 | return self 141 | 142 | def __init__(self, **kw): 143 | """Called either via Relation(), or via from_native(). 144 | In both cases, the Relation object cannot perform 145 | queries until we know what classes are on both sides 146 | of the relation. 147 | 148 | If it's called via from_native, then a later call to 149 | with_parent() provides parent information. 150 | 151 | If it's called as Relation(), the relatedClassName is 152 | discovered either on the first added object, or 153 | by querying the server to retrieve the schema. 154 | """ 155 | # Name of the key on the parent object. 156 | self.key = None 157 | self.parentObject = None 158 | self.relatedClassName = None 159 | 160 | # Called via from_native() 161 | if 'className' in kw: 162 | self.relatedClassName = kw['className'] 163 | 164 | # Called via Relation(...) 165 | if 'parentObject' in kw: 166 | self.parentObject = kw['parentObject'] 167 | self.key = kw['key'] 168 | 169 | def __repr__(self): 170 | className = objectId = None 171 | if self.parentObject is not None: 172 | className = self.parentObject.className 173 | objectId = self.parentObject.objectId 174 | repr = u'' % \ 175 | (className, 176 | objectId, 177 | self.relatedClassName) 178 | return repr 179 | 180 | def _to_native(self): 181 | # Saving relations is a separate operation and thus should never need 182 | # to convert this field _to_native 183 | return None 184 | 185 | def add(self, objs): 186 | """Adds a Parse.Object or an array of Parse.Objects to the relation.""" 187 | if type(objs) is not list: 188 | objs = [objs] 189 | if self.relatedClassName is None: 190 | # find the related class from the first object added 191 | self.relatedClassName = objs[0].className 192 | setattr(self.parentObject, self.key, self) 193 | objectsId = [] 194 | for obj in objs: 195 | if not hasattr(obj, 'objectId') or obj.objectId is None: 196 | obj.save() 197 | objectsId.append(obj.objectId) 198 | self.parentObject.addRelation(self.key, 199 | self.relatedClassName, 200 | objectsId) 201 | 202 | def remove(self, objs): 203 | """Removes an array of, or one Parse.Object from this relation.""" 204 | if type(objs) is not list: 205 | objs = [objs] 206 | objectsId = [] 207 | for obj in objs: 208 | if hasattr(obj, 'objectId'): 209 | objectsId.append(obj.objectId) 210 | self.parentObject.removeRelation(self.key, 211 | self.relatedClassName, 212 | objectsId) 213 | 214 | def query(self): 215 | """Returns a Parse.Query limited to objects in this relation.""" 216 | if self.relatedClassName is None: 217 | self._probe_for_relation_class() 218 | key = '%s__relatedTo' % (self.key,) 219 | kw = {key: self.parentObject} 220 | relatedClass = Object.factory(self.relatedClassName) 221 | q = relatedClass.Query.all().filter(**kw) 222 | return q 223 | 224 | def _probe_for_relation_class(self): 225 | """Retrive the schema from the server to find related class.""" 226 | schema = self.parentObject.__class__.schema() 227 | fields = schema['fields'] 228 | relatedColumn = fields[self.key] 229 | columnType = relatedColumn['type'] 230 | if columnType == 'Relation': 231 | self.relatedClassName = relatedColumn['targetClass'] 232 | else: 233 | raise ParseError( 234 | 'Column type is %s, expected Relation' % (columnType,)) 235 | 236 | 237 | @complex_type() 238 | class Date(ParseType): 239 | FORMAT = '%Y-%m-%dT%H:%M:%S.%f%Z' 240 | 241 | @classmethod 242 | def from_native(cls, **kw): 243 | return cls._from_str(kw.get('iso', '')) 244 | 245 | @staticmethod 246 | def _from_str(date_str): 247 | """turn a ISO 8601 string into a datetime object""" 248 | return datetime.datetime.strptime(date_str[:-1] + 'UTC', Date.FORMAT) 249 | 250 | def __init__(self, date): 251 | """Can be initialized either with a string or a datetime""" 252 | if isinstance(date, datetime.datetime): 253 | self._date = date 254 | elif isinstance(date, six.string_types): 255 | self._date = Date._from_str(date) 256 | 257 | def _to_native(self): 258 | return { #parse expects an iso8601 with 3 digits milliseonds and not 6 259 | '__type': 'Date', 'iso': '{0}Z'.format(self._date.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]) 260 | } 261 | 262 | 263 | @complex_type('Bytes') 264 | class Binary(ParseType): 265 | 266 | @classmethod 267 | def from_native(cls, **kw): 268 | return cls(kw.get('base64', '')) 269 | 270 | def __init__(self, encoded_string): 271 | self._encoded = encoded_string 272 | self._decoded = str(base64.b64decode(self._encoded)) 273 | 274 | def _to_native(self): 275 | return {'__type': 'Bytes', 'base64': self._encoded} 276 | 277 | 278 | @complex_type() 279 | class Array(ParseType): 280 | 281 | @classmethod 282 | def from_native(cls, **kw): 283 | return cls(kw) 284 | 285 | def __init__(self, elements): 286 | self._elements = elements 287 | self._orig_elements = elements 288 | 289 | def _to_native(self): 290 | return self._elements 291 | 292 | 293 | 294 | @complex_type() 295 | class GeoPoint(ParseType): 296 | 297 | @classmethod 298 | def from_native(cls, **kw): 299 | return cls(kw.get('latitude'), kw.get('longitude')) 300 | 301 | def __init__(self, latitude, longitude): 302 | self.latitude = latitude 303 | self.longitude = longitude 304 | 305 | def _to_native(self): 306 | return { 307 | '__type': 'GeoPoint', 308 | 'latitude': self.latitude, 309 | 'longitude': self.longitude 310 | } 311 | 312 | 313 | @complex_type() 314 | class File(ParseType, ParseBase): 315 | ENDPOINT_ROOT = '/'.join([API_ROOT, 'files']) 316 | 317 | @classmethod 318 | def from_native(cls, **kw): 319 | return cls(**kw) 320 | 321 | def __init__(self, name, content=None, mimetype=None, url=None): 322 | self._name = name 323 | self._file_url = url 324 | self._api_url = '/'.join([API_ROOT, 'files', name]) 325 | self._content = content 326 | self._mimetype = mimetype or mimetypes.guess_type(name) 327 | if not content and not url: 328 | with open(name) as f: 329 | content = f.read() 330 | self._content = content 331 | 332 | def __repr__(self): 333 | return '' % (getattr(self, '_name', None)) 334 | 335 | def _to_native(self): 336 | return { 337 | '__type': 'File', 338 | 'name': self._name, 339 | 'url': self._file_url 340 | } 341 | 342 | def save(self, batch=False): 343 | if self.url is not None: 344 | raise ParseError("Files can't be overwritten") 345 | uri = '/'.join([self.__class__.ENDPOINT_ROOT, self.name]) 346 | headers = {'Content-type': self.mimetype} 347 | response = self.__class__.POST(uri, extra_headers=headers, batch=batch, _body=self._content) 348 | self._file_url = response['url'] 349 | self._name = response['name'] 350 | self._api_url = '/'.join([API_ROOT, 'files', self._name]) 351 | 352 | if batch: 353 | return response, lambda response_dict: None 354 | 355 | def delete(self, batch=False): 356 | uri = "/".join([self.__class__.ENDPOINT_ROOT, self.name]) 357 | response = self.__class__.DELETE(uri, batch=batch) 358 | 359 | if batch: 360 | return response, lambda response_dict: None 361 | 362 | mimetype = property(lambda self: self._mimetype) 363 | url = property(lambda self: self._file_url) 364 | name = property(lambda self: self._name) 365 | _absolute_url = property(lambda self: self._api_url) 366 | 367 | 368 | @complex_type() 369 | class ACL(ParseType): 370 | 371 | @classmethod 372 | def from_native(cls, **kw): 373 | return cls(kw) 374 | 375 | def __init__(self, acl=None): 376 | self._acl = acl or {} 377 | 378 | def _to_native(self): 379 | return self._acl 380 | 381 | def __repr__(self): 382 | return '%s(%s)' % (type(self).__name__, repr(self._acl)) 383 | 384 | def set_default(self, read=False, write=False): 385 | self._set_permission("*", read, write) 386 | 387 | def set_role(self, role, read=False, write=False): 388 | if isinstance(role, ParseResource): 389 | self._set_permission("role:%s" % role.name, read, write) 390 | else: 391 | self._set_permission("role:%s" % role, read, write) 392 | 393 | def set_user(self, user, read=False, write=False): 394 | if isinstance(user, ParseResource): 395 | self._set_permission(user.objectId, read, write) 396 | else: 397 | self._set_permission(user, read, write) 398 | 399 | def set_all(self, permissions): 400 | self._acl.clear() 401 | for k, v in permissions.items(): 402 | self._set_permission(k, **v) 403 | 404 | def _set_permission(self, name, read=False, write=False): 405 | permissions = {} 406 | if read is True: 407 | permissions["read"] = True 408 | if write is True: 409 | permissions["write"] = True 410 | if len(permissions): 411 | self._acl[name] = permissions 412 | else: 413 | self._acl.pop(name, None) 414 | 415 | 416 | class Function(ParseBase): 417 | ENDPOINT_ROOT = '/'.join((API_ROOT, 'functions')) 418 | 419 | def __init__(self, name): 420 | self.name = name 421 | 422 | def __call__(self, **kwargs): 423 | return self.POST('/' + self.name, **kwargs) 424 | 425 | class Job(ParseBase): 426 | ENDPOINT_ROOT = '/'.join((API_ROOT, 'jobs')) 427 | 428 | def __init__(self, name): 429 | self.name = name 430 | 431 | def __call__(self, **kwargs): 432 | return self.POST('/' + self.name, **kwargs) 433 | 434 | 435 | class ParseResource(ParseBase): 436 | 437 | PROTECTED_ATTRIBUTES = ['objectId', 'createdAt', 'updatedAt'] 438 | 439 | @property 440 | def _editable_attrs(self): 441 | protected_attrs = self.__class__.PROTECTED_ATTRIBUTES 442 | allowed = lambda a: a not in protected_attrs and not a.startswith('_') 443 | return dict([(k, v) for k, v in self.__dict__.items() if allowed(k)]) 444 | 445 | def __init__(self, **kw): 446 | self.objectId = None 447 | self._init_attrs(kw) 448 | 449 | def __getattr__(self, attr): 450 | # if object is not loaded and attribute is missing, try to load it 451 | if not self.__dict__.get('_is_loaded', True): 452 | del self._is_loaded 453 | self._init_attrs(self.GET(self._absolute_url)) 454 | return object.__getattribute__(self, attr) #preserve default if attr not exists 455 | 456 | def _init_attrs(self, args): 457 | for key, value in six.iteritems(args): 458 | # https://github.com/milesrichardson/ParsePy/issues/155 459 | try: 460 | setattr(self, key, ParseType.convert_from_parse(key, value)) 461 | except AttributeError: 462 | continue 463 | 464 | def _to_native(self): 465 | return ParseType.convert_to_parse(self) 466 | 467 | 468 | def _get_updated_datetime(self): 469 | return self.__dict__.get('_updated_at') and self._updated_at._date 470 | 471 | def _set_updated_datetime(self, value): 472 | self._updated_at = Date(value) 473 | 474 | def _get_created_datetime(self): 475 | return self.__dict__.get('_created_at') and self._created_at._date 476 | 477 | def _set_created_datetime(self, value): 478 | self._created_at = Date(value) 479 | 480 | def save(self, batch=False): 481 | if self.objectId: 482 | return self._update(batch=batch) 483 | else: 484 | return self._create(batch=batch) 485 | 486 | def _create(self, batch=False): 487 | uri = self.__class__.ENDPOINT_ROOT 488 | response = self.__class__.POST(uri, batch=batch, **self._to_native()) 489 | 490 | def call_back(response_dict): 491 | self.createdAt = self.updatedAt = response_dict['createdAt'] 492 | self.objectId = response_dict['objectId'] 493 | 494 | if batch: 495 | return response, call_back 496 | else: 497 | call_back(response) 498 | 499 | def _update(self, batch=False): 500 | response = self.__class__.PUT(self._absolute_url, batch=batch, **self._to_native()) 501 | 502 | def call_back(response_dict): 503 | self.updatedAt = response_dict['updatedAt'] 504 | 505 | if batch: 506 | return response, call_back 507 | else: 508 | call_back(response) 509 | 510 | def delete(self, batch=False): 511 | response = self.__class__.DELETE(self._absolute_url, batch=batch) 512 | if batch: 513 | return response, lambda response_dict: None 514 | 515 | @property 516 | def className(self): 517 | return self.__class__.__name__ 518 | 519 | @property 520 | def _absolute_url(self): 521 | return '%s/%s' % (self.__class__.ENDPOINT_ROOT, self.objectId) 522 | 523 | createdAt = property(_get_created_datetime, _set_created_datetime) 524 | updatedAt = property(_get_updated_datetime, _set_updated_datetime) 525 | 526 | def __repr__(self): 527 | return '<%s:%s>' % (self.__class__.__name__, self.objectId) 528 | 529 | 530 | class ObjectMetaclass(type): 531 | def __new__(mcs, name, bases, dct): 532 | cls = super(ObjectMetaclass, mcs).__new__(mcs, name, bases, dct) 533 | # attr check must be here because of specific six.with_metaclass implemetantion where metaclass is used also for 534 | # internal NewBase which hasn't set_endpoint_root method 535 | if hasattr(cls, 'set_endpoint_root'): 536 | cls.set_endpoint_root() 537 | cls.Query = QueryManager(cls) 538 | return cls 539 | 540 | 541 | class Object(six.with_metaclass(ObjectMetaclass, ParseResource)): 542 | ENDPOINT_ROOT = '/'.join([API_ROOT, 'classes']) 543 | 544 | @classmethod 545 | def factory(cls, class_name): 546 | """find proper Object subclass matching class_name 547 | system types like _User are mapped to types without underscore (parse_resr.user.User) 548 | If user don't declare matching type, class is created on the fly 549 | """ 550 | class_name = str(class_name.lstrip('_')) 551 | types = ParseResource.__subclasses__() 552 | while types: 553 | t = types.pop() 554 | if t.__name__ == class_name: 555 | return t 556 | types.extend(t.__subclasses__()) 557 | else: 558 | return type(class_name, (Object,), {}) 559 | 560 | @classmethod 561 | def set_endpoint_root(cls): 562 | root = '/'.join([API_ROOT, 'classes', cls.__name__]) 563 | if cls.ENDPOINT_ROOT != root: 564 | cls.ENDPOINT_ROOT = root 565 | return cls.ENDPOINT_ROOT 566 | 567 | @classmethod 568 | def schema(cls): 569 | """Retrieves the class' schema.""" 570 | root = '/'.join([API_ROOT, 'schemas', cls.__name__]) 571 | schema = cls.GET(root) 572 | return schema 573 | 574 | @classmethod 575 | def schema_delete_field(cls, key): 576 | """Deletes a field.""" 577 | root = '/'.join([API_ROOT, 'schemas', cls.__name__]) 578 | payload = { 579 | 'className': cls.__name__, 580 | 'fields': { 581 | key: { 582 | '__op': 'Delete' 583 | } 584 | } 585 | } 586 | cls.PUT(root, **payload) 587 | 588 | @property 589 | def _absolute_url(self): 590 | if not self.objectId: 591 | return None 592 | return '/'.join([self.__class__.ENDPOINT_ROOT, self.objectId]) 593 | 594 | @property 595 | def as_pointer(self): 596 | return Pointer(self) 597 | 598 | def increment(self, key, amount=1): 599 | """ 600 | Increment one value in the object. Note that this happens immediately: 601 | it does not wait for save() to be called 602 | """ 603 | payload = { 604 | key: { 605 | '__op': 'Increment', 606 | 'amount': amount 607 | } 608 | } 609 | self.__class__.PUT(self._absolute_url, **payload) 610 | self.__dict__[key] += amount 611 | 612 | def remove(self, key): 613 | """ 614 | Clear a column value in the object. Note that this happens immediately: 615 | it does not wait for save() to be called. 616 | """ 617 | payload = { 618 | key: { 619 | '__op': 'Delete' 620 | } 621 | } 622 | self.__class__.PUT(self._absolute_url, **payload) 623 | del self.__dict__[key] 624 | 625 | def removeRelation(self, key, className, objectsId): 626 | self.manageRelation('RemoveRelation', key, className, objectsId) 627 | 628 | def addRelation(self, key, className, objectsId): 629 | self.manageRelation('AddRelation', key, className, objectsId) 630 | 631 | def manageRelation(self, action, key, className, objectsId): 632 | objects = [{ 633 | "__type": "Pointer", 634 | "className": className, 635 | "objectId": objectId 636 | } for objectId in objectsId] 637 | 638 | payload = { 639 | key: { 640 | "__op": action, 641 | "objects": objects 642 | } 643 | } 644 | self.__class__.PUT(self._absolute_url, **payload) 645 | 646 | def relation(self, key): 647 | if not hasattr(self, key): 648 | return Relation(parentObject=self, key=key) 649 | try: 650 | return getattr(self, key).with_parent(parentObject=self, key=key) 651 | except: 652 | raise ParseError("Column '%s' is not a Relation." % (key,)) 653 | 654 | def addToArray(self, key, objects): 655 | payload = { 656 | key: { 657 | '__op': 'Add', 'objects': objects 658 | } 659 | } 660 | self.__class__.PUT(self._absolute_url, **payload) 661 | self.__dict__[key] = self.__dict__.get(key, []) + objects 662 | 663 | def addUniqueToArray(self, key, objects): 664 | payload = { 665 | key: { 666 | '__op': 'AddUnique', 'objects': objects 667 | } 668 | } 669 | self.__class__.PUT(self._absolute_url, **payload) 670 | data = self.__dict__.get(key, []) 671 | self.__dict__[key] = data + [x for x in objects if x not in data] 672 | 673 | def removeFromArray(self, key, objects): 674 | payload = { 675 | key: { 676 | '__op': 'Remove', 'objects': objects 677 | } 678 | } 679 | self.__class__.PUT(self._absolute_url, **payload) 680 | self.__dict__[key] = [x for x in self.__dict__.get(key, []) if x not in objects] 681 | -------------------------------------------------------------------------------- /parse_rest/installation.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | from parse_rest.connection import API_ROOT 15 | from parse_rest.datatypes import ParseResource 16 | from parse_rest.query import QueryManager 17 | 18 | 19 | class Installation(ParseResource): 20 | ENDPOINT_ROOT = '/'.join([API_ROOT, 'installations']) 21 | 22 | @classmethod 23 | def _get_installation_url(cls, installation_id): 24 | """ 25 | Get the URL for RESTful operations on this particular installation 26 | """ 27 | return '/'.join([cls.ENDPOINT_ROOT, installation_id]) 28 | 29 | @classmethod 30 | def update_channels(cls, installation_id, channels_to_add=set(), 31 | channels_to_remove=set(), **kw): 32 | """ 33 | Allow an application to manually subscribe or unsubscribe an 34 | installation to a certain push channel in a unified operation. 35 | 36 | this is based on: 37 | https://www.parse.com/docs/rest#installations-updating 38 | 39 | installation_id: the installation id you'd like to add a channel to 40 | channels_to_add: the name of the channel you'd like to subscribe the user to 41 | channels_to_remove: the name of the channel you'd like to unsubscribe the user from 42 | 43 | """ 44 | installation_url = cls._get_installation_url(installation_id) 45 | current_config = cls.GET(installation_url) 46 | 47 | new_channels = list(set(current_config['channels']).union(channels_to_add).difference(channels_to_remove)) 48 | 49 | cls.PUT(installation_url, channels=new_channels) 50 | 51 | 52 | class Push(ParseResource): 53 | ENDPOINT_ROOT = '/'.join([API_ROOT, 'push']) 54 | 55 | @classmethod 56 | def _send(cls, data, where=None, **kw): 57 | if where != None: 58 | kw['where'] = where 59 | 60 | # allow channels to be specified even if "where" is as well 61 | if "channels" in kw: 62 | kw['where']["channels"] = {"$in": kw.pop("channels")} 63 | 64 | return cls.POST('', data=data, **kw) 65 | 66 | @classmethod 67 | def alert(cls, data, where=None, **kw): 68 | cls._send(data, where=where, **kw) 69 | 70 | @classmethod 71 | def message(cls, message, where=None, **kw): 72 | cls._send({'alert': message}, where=where, **kw) 73 | 74 | Installation.Query = QueryManager(Installation) 75 | -------------------------------------------------------------------------------- /parse_rest/query.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | import json 15 | import copy 16 | import collections 17 | 18 | 19 | class QueryError(Exception): 20 | '''Query error base class''' 21 | 22 | def __init__(self, message, status_code=None): 23 | super(QueryError, self).__init__(message) 24 | if status_code: 25 | self.status_code = status_code 26 | 27 | 28 | class QueryResourceDoesNotExist(QueryError): 29 | '''Query returned no results''' 30 | pass 31 | 32 | 33 | class QueryResourceMultipleResultsReturned(QueryError): 34 | '''Query was supposed to return unique result, returned more than one''' 35 | pass 36 | 37 | 38 | class QueryManager(object): 39 | 40 | def __init__(self, model_class): 41 | self.model_class = model_class 42 | 43 | def _fetch(self, **kw): 44 | klass = self.model_class 45 | uri = self.model_class.ENDPOINT_ROOT 46 | return [klass(**it) for it in klass.GET(uri, **kw).get('results')] 47 | 48 | def _count(self, **kw): 49 | kw.update({"count": 1}) 50 | return self.model_class.GET(self.model_class.ENDPOINT_ROOT, **kw).get('count') 51 | 52 | def all(self): 53 | return Queryset(self) 54 | 55 | def filter(self, **kw): 56 | return self.all().filter(**kw) 57 | 58 | def fetch(self): 59 | return self.all().fetch() 60 | 61 | def get(self, **kw): 62 | return self.filter(**kw).get() 63 | 64 | 65 | class Queryset(object): 66 | 67 | OPERATORS = [ 68 | 'lt', 'lte', 'gt', 'gte', 'ne', 'in', 'nin', 'exists', 'select', 'dontSelect', 'all', 'regex', 'relatedTo', 'nearSphere' 69 | ] 70 | 71 | @staticmethod 72 | def convert_to_parse(value): 73 | from parse_rest.datatypes import ParseType 74 | return ParseType.convert_to_parse(value, as_pointer=True) 75 | 76 | @classmethod 77 | def extract_filter_operator(cls, parameter): 78 | for op in cls.OPERATORS: 79 | underscored = '__%s' % op 80 | if parameter.endswith(underscored): 81 | return parameter[:-len(underscored)], op 82 | return parameter, None 83 | 84 | def __init__(self, manager): 85 | self._manager = manager 86 | self._where = collections.defaultdict(dict) 87 | self._select_related = [] 88 | self._options = {} 89 | self._result_cache = None 90 | 91 | def __deepcopy__(self, memo): 92 | q = self.__class__(self._manager) 93 | q._where = copy.deepcopy(self._where, memo) 94 | q._options = copy.deepcopy(self._options, memo) 95 | q._select_related.extend(self._select_related) 96 | return q 97 | 98 | def __iter__(self): 99 | return iter(self._fetch()) 100 | 101 | def __len__(self): 102 | #don't use count query for len operator 103 | #count doesn't return real size of result in all cases (eg if query contains skip option) 104 | return len(self._fetch()) 105 | 106 | def __getitem__(self, key): 107 | if isinstance(key, slice): 108 | raise AttributeError("Slice is not supported for now.") 109 | return self._fetch()[key] 110 | 111 | def _fetch(self, count=False): 112 | if self._result_cache is not None: 113 | return len(self._result_cache) if count else self._result_cache 114 | """ 115 | Return a list of objects matching query, or if count == True return 116 | only the number of objects matching. 117 | """ 118 | options = dict(self._options) # make a local copy 119 | if self._where: 120 | # JSON encode WHERE values 121 | options['where'] = json.dumps(self._where) 122 | if self._select_related: 123 | options['include'] = ','.join(self._select_related) 124 | if count: 125 | return self._manager._count(**options) 126 | 127 | self._result_cache = self._manager._fetch(**options) 128 | return self._result_cache 129 | 130 | def filter(self, **kw): 131 | q = copy.deepcopy(self) 132 | for name, value in kw.items(): 133 | parse_value = Queryset.convert_to_parse(value) 134 | attr, operator = Queryset.extract_filter_operator(name) 135 | attr = attr.replace("__", ".") 136 | if operator is None: 137 | q._where[attr] = parse_value 138 | elif operator == 'relatedTo': 139 | q._where['$' + operator] = {'object': parse_value, 'key': attr} 140 | else: 141 | if not isinstance(q._where[attr], dict): 142 | q._where[attr] = {} 143 | q._where[attr]['$' + operator] = parse_value 144 | return q 145 | 146 | def limit(self, value): 147 | q = copy.deepcopy(self) 148 | q._options['limit'] = int(value) 149 | return q 150 | 151 | def skip(self, value): 152 | q = copy.deepcopy(self) 153 | q._options['skip'] = int(value) 154 | return q 155 | 156 | def keys(self, *fields): 157 | q = copy.deepcopy(self) 158 | q._options['keys'] = ','.join(fields) 159 | return q 160 | 161 | def order_by(self, order, descending=False): 162 | q = copy.deepcopy(self) 163 | # add a minus sign before the order value if descending == True 164 | q._options['order'] = descending and ('-' + order) or order 165 | return q 166 | 167 | def select_related(self, *fields): 168 | q = copy.deepcopy(self) 169 | q._select_related.extend(fields) 170 | return q 171 | 172 | def count(self): 173 | return self._fetch(count=True) 174 | 175 | def exists(self): 176 | return bool(self) 177 | 178 | def get(self): 179 | results = self._fetch() 180 | if len(results) == 0: 181 | error_message = 'Query against %s returned no results' % ( 182 | self._manager.model_class.ENDPOINT_ROOT) 183 | raise QueryResourceDoesNotExist(error_message, 184 | status_code=404) 185 | if len(results) >= 2: 186 | error_message = 'Query against %s returned multiple results' % ( 187 | self._manager.model_class.ENDPOINT_ROOT) 188 | raise QueryResourceMultipleResultsReturned(error_message, 189 | status_code=404) 190 | return results[0] 191 | 192 | def __repr__(self): 193 | return repr(self._fetch()) 194 | -------------------------------------------------------------------------------- /parse_rest/role.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | 15 | from parse_rest.connection import API_ROOT 16 | from parse_rest.datatypes import Object 17 | from parse_rest.query import QueryManager 18 | 19 | 20 | class Role(Object): 21 | ''' 22 | A Role is like a regular Parse object (can be modified and saved) but 23 | it requires additional methods and functionality 24 | ''' 25 | ENDPOINT_ROOT = '/'.join([API_ROOT, 'roles']) 26 | 27 | @property 28 | def className(self): 29 | return '_Role' 30 | 31 | def __repr__(self): 32 | return '' % (getattr(self, 'name', None), self.objectId) 33 | 34 | @classmethod 35 | def set_endpoint_root(cls): 36 | return cls.ENDPOINT_ROOT 37 | 38 | 39 | Role.Query = QueryManager(Role) 40 | -------------------------------------------------------------------------------- /parse_rest/test_relations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #-*- coding: utf-8 -*- 3 | 4 | """ 5 | Contains unit tests for the Python Parse REST API wrapper 6 | """ 7 | from __future__ import print_function 8 | 9 | import os 10 | import sys 11 | import subprocess 12 | import unittest 13 | import datetime 14 | import six 15 | from itertools import chain 16 | 17 | from parse_rest.core import ResourceRequestNotFound 18 | from parse_rest.core import ResourceRequestBadRequest 19 | from parse_rest.core import ParseError 20 | from parse_rest.connection import register, ParseBatcher 21 | from parse_rest.datatypes import GeoPoint, Object, Function, Pointer, Relation 22 | from parse_rest.user import User 23 | from parse_rest import query 24 | from parse_rest.installation import Push 25 | 26 | try: 27 | import settings_local 28 | except ImportError: 29 | sys.exit('You must create a settings_local.py file with APPLICATION_ID, ' \ 30 | 'REST_API_KEY, MASTER_KEY variables set') 31 | 32 | register( 33 | getattr(settings_local, 'APPLICATION_ID'), 34 | getattr(settings_local, 'REST_API_KEY'), 35 | master_key=getattr(settings_local, 'MASTER_KEY') 36 | ) 37 | 38 | GLOBAL_JSON_TEXT = """{ 39 | "applications": { 40 | "_default": { 41 | "link": "parseapi" 42 | }, 43 | "parseapi": { 44 | "applicationId": "%s", 45 | "masterKey": "%s" 46 | } 47 | }, 48 | "global": { 49 | "parseVersion": "1.1.16" 50 | } 51 | } 52 | """ 53 | 54 | 55 | class Game(Object): 56 | pass 57 | 58 | 59 | class GameScore(Object): 60 | pass 61 | 62 | 63 | class TestNoRelation(unittest.TestCase): 64 | def setUp(self): 65 | try: 66 | Game.schema_delete_field('scores') 67 | except ResourceRequestBadRequest: 68 | # fails if the field doesn't exist 69 | pass 70 | self.game = Game(name="foobar") 71 | 72 | def testQueryWithNoRelationOnline(self): 73 | """If the online schema lacks the relation, we cannot query.""" 74 | with self.assertRaises(KeyError): 75 | rel = self.game.relation('scores') 76 | rel.query() 77 | 78 | 79 | class TestRelation(unittest.TestCase): 80 | @classmethod 81 | def setUpClass(cls): 82 | # prime the schema with a relation field for GameScore 83 | score1 = GameScore(score=1337, player_name='John Doe', cheat_mode=False) 84 | game = Game(name="foobar") 85 | game.save() 86 | rel = game.relation('scores') 87 | rel.add(score1) 88 | 89 | def setUp(self): 90 | self.score1 = GameScore(score=1337, player_name='John Doe', cheat_mode=False) 91 | self.score2 = GameScore(score=1337, player_name='Jane Doe', cheat_mode=False) 92 | self.score3 = GameScore(score=1337, player_name='Joan Doe', cheat_mode=False) 93 | self.score4 = GameScore(score=1337, player_name='Jeff Doe', cheat_mode=False) 94 | self.game = Game(name="foobar") 95 | self.game.save() 96 | self.rel = self.game.relation('scores') 97 | 98 | def tearDown(self): 99 | game_score = getattr(self.score1, 'score', None) 100 | game_name = getattr(self.game, 'name', None) 101 | if game_score: 102 | ParseBatcher().batch_delete(GameScore.Query.filter(score=game_score)) 103 | if game_name: 104 | ParseBatcher().batch_delete(Game.Query.filter(name=game_name)) 105 | 106 | @classmethod 107 | def tearDownClass(cls): 108 | Game.schema_delete_field('scores') 109 | 110 | def testRelationsAdd(self): 111 | """Add multiple objects to a relation.""" 112 | self.rel.add(self.score1) 113 | scores = self.rel.query() 114 | self.assertEqual(len(scores), 1) 115 | self.assertEqual(scores[0].player_name, 'John Doe') 116 | 117 | self.rel.add(self.score2) 118 | scores = self.rel.query() 119 | self.assertEqual(len(scores), 2) 120 | 121 | self.rel.add([self.score3, self.score4]) 122 | scores = self.rel.query() 123 | self.assertEqual(len(scores), 4) 124 | 125 | def testRelationQueryLimitsToRelation(self): 126 | """Relational query limits results to objects in the relation.""" 127 | self.rel.add([self.score1, self.score2]) 128 | gamescore3 = GameScore(score=1337) 129 | gamescore3.save() 130 | # score saved but not associated with the game 131 | q = self.rel.query() 132 | scores = q.filter(score__gte=1337) 133 | self.assertEqual(len(scores), 2) 134 | 135 | def testRemoval(self): 136 | """Test if a specific object can be removed from a relation.""" 137 | self.rel.add([self.score1, self.score2, self.score3]) 138 | self.rel.remove(self.score1) 139 | self.rel.remove(self.score2) 140 | scores = self.rel.query() 141 | self.assertEqual(scores[0].player_name, 'Joan Doe') 142 | 143 | def testSchema(self): 144 | """Retrieve a schema for the class.""" 145 | schema = Game.schema() 146 | self.assertEqual(schema['className'], 'Game') 147 | fields = schema['fields'] 148 | self.assertEqual(fields['scores']['type'], 'Relation') 149 | 150 | def testWrongType(self): 151 | """Adding wrong type fails silently.""" 152 | self.rel.add(self.score1) 153 | self.rel.add(self.score2) 154 | self.rel.add(self.game) # should fail to add this 155 | scores = self.rel.query() 156 | self.assertEqual(len(scores), 2) 157 | 158 | def testNoTypeSetParseHasColumn(self): 159 | """Query can run before anything is added to the relation, 160 | if the schema online has already defined the relation. 161 | """ 162 | scores = self.rel.query() 163 | self.assertEqual(len(scores), 0) 164 | 165 | def testWrongColumnForRelation(self): 166 | """Should get a ParseError if we specify a relation on 167 | a column that is not a relation. 168 | """ 169 | with self.assertRaises(ParseError): 170 | rel = self.game.relation("name") 171 | rel.query() 172 | 173 | def testNonexistentColumnForRelation(self): 174 | """Should get a ParseError if we specify a relation on 175 | a column that is not a relation. 176 | """ 177 | with self.assertRaises(KeyError): 178 | rel = self.game.relation("nonexistent") 179 | rel.query() 180 | 181 | def testRepr(self): 182 | s = "*** %s ***" % (self.rel) 183 | self.assertRegex(s, '') 184 | 185 | def testWithParent(self): 186 | """Rehydrating a relation from an instance on the server. 187 | With_parent is called by relation() when the object was 188 | retrieved from the server. This test is for code coverage. 189 | """ 190 | game2 = Game.Query.get(objectId=self.game.objectId) 191 | self.assertTrue(hasattr(game2, 'scores')) 192 | rel2 = game2.relation('scores') 193 | self.assertIsInstance(rel2, Relation) 194 | 195 | 196 | def run_tests(): 197 | """Run all tests in the parse_rest package""" 198 | tests = unittest.TestLoader().loadTestsFromNames(['parse_rest.tests']) 199 | t = unittest.TextTestRunner(verbosity=1) 200 | t.run(tests) 201 | 202 | if __name__ == "__main__": 203 | # command line 204 | unittest.main() 205 | -------------------------------------------------------------------------------- /parse_rest/tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #-*- coding: utf-8 -*- 3 | 4 | """ 5 | Contains unit tests for the Python Parse REST API wrapper 6 | """ 7 | from __future__ import print_function 8 | 9 | import os 10 | import sys 11 | import subprocess 12 | import unittest 13 | import datetime 14 | import six 15 | from itertools import chain 16 | 17 | from parse_rest.core import ResourceRequestNotFound 18 | from parse_rest.connection import register, ParseBatcher, SessionToken, MasterKey 19 | from parse_rest.datatypes import GeoPoint, Object, Function, Pointer 20 | from parse_rest.user import User 21 | from parse_rest import query 22 | from parse_rest.installation import Push 23 | 24 | try: 25 | import settings_local 26 | except ImportError: 27 | sys.exit('You must create a settings_local.py file with APPLICATION_ID, ' \ 28 | 'REST_API_KEY, MASTER_KEY variables set') 29 | 30 | 31 | register( 32 | getattr(settings_local, 'APPLICATION_ID'), 33 | getattr(settings_local, 'REST_API_KEY'), 34 | master_key=getattr(settings_local, 'MASTER_KEY') 35 | ) 36 | 37 | GLOBAL_JSON_TEXT = """{ 38 | "applications": { 39 | "_default": { 40 | "link": "parseapi" 41 | }, 42 | "parseapi": { 43 | "applicationId": "%s", 44 | "masterKey": "%s" 45 | } 46 | }, 47 | "global": { 48 | "parseVersion": "1.1.16" 49 | } 50 | } 51 | """ 52 | 53 | 54 | class Game(Object): 55 | pass 56 | 57 | 58 | class GameScore(Object): 59 | pass 60 | 61 | 62 | class GameMap(Object): 63 | pass 64 | 65 | 66 | class GameMode(Object): 67 | pass 68 | 69 | 70 | class City(Object): 71 | pass 72 | 73 | 74 | class Review(Object): 75 | pass 76 | 77 | 78 | class CollectedItem(Object): 79 | pass 80 | 81 | 82 | class TestObject(unittest.TestCase): 83 | def setUp(self): 84 | self.score = GameScore(score=1337, player_name='John Doe', cheat_mode=False, achievements=['No Miss', 'Ninja']) 85 | self.sao_paulo = City(name='São Paulo', location=GeoPoint(-23.5, -46.6167)) 86 | self.collected_item = CollectedItem(type="Sword", isAwesome=True) 87 | 88 | def tearDown(self): 89 | city_name = getattr(self.sao_paulo, 'name', None) 90 | game_score = getattr(self.score, 'score', None) 91 | collected_item_type = getattr(self.collected_item, 'type', None) 92 | if city_name: 93 | ParseBatcher().batch_delete(City.Query.filter(name=city_name)) 94 | if game_score: 95 | ParseBatcher().batch_delete(GameScore.Query.filter(score=game_score)) 96 | if collected_item_type: 97 | ParseBatcher().batch_delete(CollectedItem.Query.filter(type=collected_item_type)) 98 | 99 | def testCanInitialize(self): 100 | self.assertEqual(self.score.score, 1337, 'Could not set score') 101 | 102 | def testCanInstantiateParseType(self): 103 | self.assertEqual(self.sao_paulo.location.latitude, -23.5) 104 | 105 | def testFactory(self): 106 | self.assertEqual(Object.factory('_User'), User) 107 | self.assertEqual(Object.factory('GameScore'), GameScore) 108 | 109 | def testCanSaveDates(self): 110 | now = datetime.datetime.now() 111 | self.score.last_played = now 112 | self.score.save() 113 | self.assertEqual(self.score.last_played, now, 'Could not save date') 114 | 115 | def testCanCreateNewObject(self): 116 | self.score.save() 117 | object_id = self.score.objectId 118 | 119 | self.assertIsNotNone(object_id, 'Can not create object') 120 | self.assertIsInstance(object_id, six.string_types) 121 | self.assertIsInstance(self.score.createdAt, datetime.datetime) 122 | self.assertTrue(GameScore.Query.filter(objectId=object_id).exists(), 'Can not create object') 123 | 124 | def testCanUpdateExistingObject(self): 125 | self.sao_paulo.save() 126 | self.sao_paulo.country = 'Brazil' 127 | self.sao_paulo.save() 128 | self.assertIsInstance(self.sao_paulo.updatedAt, datetime.datetime) 129 | 130 | city = City.Query.get(name='São Paulo') 131 | self.assertEqual(city.country, 'Brazil', 'Could not update object') 132 | 133 | def testCanDeleteExistingObject(self): 134 | self.score.save() 135 | object_id = self.score.objectId 136 | self.score.delete() 137 | self.assertFalse(GameScore.Query.filter(objectId=object_id).exists(), 138 | 'Failed to delete object %s on Parse ' % self.score) 139 | 140 | def testCanIncrementField(self): 141 | previous_score = self.score.score 142 | self.score.save() 143 | self.score.increment('score') 144 | self.assertTrue(GameScore.Query.filter(score=previous_score + 1).exists(), 145 | 'Failed to increment score on backend') 146 | 147 | def testCanRemoveField(self): 148 | self.score.save() 149 | self.score.remove('score') 150 | self.assertTrue(GameScore.Query.filter(score=None).exists(), 151 | 'Failed to remove score on backend') 152 | 153 | def testCanOperateArray(self): 154 | self.score.save() 155 | 156 | self.score.addToArray('achievements', ['Ninja Head', 'Thunder', 'Ninja']) 157 | correct = ['No Miss', 'Ninja', 'Ninja Head', 'Thunder', 'Ninja'] 158 | self.assertEqual(self.score.achievements, correct) 159 | self.assertEqual(GameScore.Query.get(objectId=self.score.objectId).achievements, correct) 160 | 161 | self.score.removeFromArray('achievements', ['Ninja']) 162 | correct = ['No Miss', 'Ninja Head', 'Thunder'] 163 | self.assertEqual(self.score.achievements, correct) 164 | self.assertEqual(GameScore.Query.get(objectId=self.score.objectId).achievements, correct) 165 | 166 | self.score.addUniqueToArray('achievements', ['Ninja Head', 'Hero']) 167 | correct = ['No Miss', 'Ninja Head', 'Thunder', 'Hero'] 168 | self.assertEqual(self.score.achievements, correct) 169 | self.assertEqual(GameScore.Query.get(objectId=self.score.objectId).achievements, correct) 170 | 171 | def testAssociatedObject(self): 172 | """test saving and associating a different object""" 173 | 174 | self.collected_item.save() 175 | self.score.item = self.collected_item 176 | self.score.save() 177 | 178 | # get the object, see if it has saved 179 | qs = GameScore.Query.get(objectId=self.score.objectId) 180 | self.assertIsInstance(qs.item, CollectedItem) 181 | self.assertEqual(qs.item.type, "Sword", "Associated CollectedItem does not have correct attributes") 182 | 183 | def testBatch(self): 184 | """test saving, updating and deleting objects in batches""" 185 | scores = [GameScore(score=s, player_name='Jane', cheat_mode=False) for s in range(5)] 186 | batcher = ParseBatcher() 187 | batcher.batch_save(scores) 188 | self.assertEqual(GameScore.Query.filter(player_name='Jane').count(), 5, 189 | "batch_save didn't create objects") 190 | self.assertTrue(all(s.objectId is not None for s in scores), 191 | "batch_save didn't record object IDs") 192 | 193 | # test updating 194 | for s in scores: 195 | s.score += 10 196 | batcher.batch_save(scores) 197 | 198 | updated_scores = GameScore.Query.filter(player_name='Jane') 199 | self.assertEqual(sorted([s.score for s in updated_scores]), 200 | list(range(10, 15)), msg="batch_save didn't update objects") 201 | 202 | # test deletion 203 | batcher.batch_delete(scores) 204 | self.assertEqual(GameScore.Query.filter(player_name='Jane').count(), 0, 205 | "batch_delete didn't delete objects") 206 | 207 | 208 | class TestPointer(unittest.TestCase): 209 | 210 | def testToNative(self): 211 | ptr = Pointer(GameScore(objectId='xyz')) 212 | self.assertEqual(ptr._to_native(), dict(__type='Pointer', className='GameScore', objectId='xyz')) 213 | ptr = Pointer(User(objectId='dh56yz', username="dhelmet@spaceballs.com")) 214 | self.assertEqual(ptr._to_native(), dict(__type='Pointer', className='_User', objectId='dh56yz')) 215 | 216 | 217 | class TestTypes(unittest.TestCase): 218 | def setUp(self): 219 | self.now = datetime.datetime.now() 220 | self.score = GameScore( 221 | score=1337, player_name='John Doe', cheat_mode=False, 222 | date_of_birth=self.now, achievements=['No Miss', 'Ninja'] 223 | ) 224 | self.sao_paulo = City( 225 | name='São Paulo', location=GeoPoint(-23.5, -46.6167) 226 | ) 227 | 228 | def testCanConvertToNative(self): 229 | native_data = self.sao_paulo._to_native() 230 | self.assertIsInstance(native_data, dict, 'Can not convert object to dict') 231 | 232 | def testCanConvertArray(self): 233 | native_data = self.score._to_native() 234 | self.assertEqual(native_data['achievements'], ['No Miss', 'Ninja']) 235 | 236 | def testCanConvertNestedLocation(self): 237 | native_sao_paulo = self.sao_paulo._to_native() 238 | location_dict = native_sao_paulo.get('location') 239 | 240 | self.assertIsInstance(location_dict, dict, 241 | 'Expected dict after conversion. Got %s' % location_dict) 242 | self.assertEqual(location_dict.get('latitude'), -23.5, 243 | 'Can not serialize geopoint data') 244 | 245 | def testCanConvertDate(self): 246 | native_date = self.score._to_native().get('date_of_birth') 247 | self.assertIsInstance(native_date, dict, 248 | 'Could not serialize date into dict') 249 | iso_date = native_date.get('iso') 250 | now = '{0}Z'.format(self.now.isoformat()[:-3]) 251 | self.assertEqual(iso_date, now, 'Expected %s. Got %s' % (now, iso_date)) 252 | 253 | 254 | class TestQuery(unittest.TestCase): 255 | """Tests of an object's Queryset""" 256 | 257 | @classmethod 258 | def setUpClass(cls): 259 | """save a bunch of GameScore objects with varying scores""" 260 | # first delete any that exist 261 | ParseBatcher().batch_delete(GameScore.Query.all()) 262 | ParseBatcher().batch_delete(Game.Query.all()) 263 | 264 | cls.game = Game(title="Candyland", creator=None) 265 | cls.game.save() 266 | 267 | cls.scores = [GameScore(score=s, player_name='John Doe', game=cls.game) for s in range(1, 6)] 268 | ParseBatcher().batch_save(cls.scores) 269 | 270 | @classmethod 271 | def tearDownClass(cls): 272 | '''delete all GameScore and Game objects''' 273 | ParseBatcher().batch_delete(chain(cls.scores, [cls.game])) 274 | 275 | def setUp(self): 276 | self.test_objects = [] 277 | 278 | def tearDown(self): 279 | '''delete additional helper objects created in perticular tests''' 280 | if self.test_objects: 281 | ParseBatcher().batch_delete(self.test_objects) 282 | self.test_objects = [] 283 | 284 | def testExists(self): 285 | """test the Queryset.exists() method""" 286 | for s in range(1, 6): 287 | self.assertTrue(GameScore.Query.filter(score=s).exists(), 288 | "exists giving false negative") 289 | self.assertFalse(GameScore.Query.filter(score=10).exists(), 290 | "exists giving false positive") 291 | 292 | def testCanFilter(self): 293 | '''test the Queryset.filter() method''' 294 | for s in self.scores: 295 | qobj = GameScore.Query.filter(objectId=s.objectId).get() 296 | self.assertEqual(qobj.objectId, s.objectId, 297 | "Getting object with .filter() failed") 298 | self.assertEqual(qobj.score, s.score, 299 | "Getting object with .filter() failed") 300 | 301 | # test relational query with other Objects 302 | num_scores = GameScore.Query.filter(game=self.game).count() 303 | self.assertTrue(num_scores == len(self.scores), 304 | "Relational query with .filter() failed") 305 | 306 | def testGetExceptions(self): 307 | '''test possible exceptions raised by Queryset.get() method''' 308 | self.assertRaises(query.QueryResourceDoesNotExist, 309 | GameScore.Query.filter(score__gt=20).get) 310 | self.assertRaises(query.QueryResourceMultipleResultsReturned, 311 | GameScore.Query.filter(score__gt=3).get) 312 | 313 | def testCanQueryDates(self): 314 | last_week = datetime.datetime.now() - datetime.timedelta(days=7) 315 | score = GameScore(name='test', last_played=last_week) 316 | score.save() 317 | self.test_objects.append(score) 318 | self.assertTrue(GameScore.Query.filter(last_played=last_week).exists(), 'Could not run query with dates') 319 | 320 | 321 | def testComparisons(self): 322 | """test comparison operators- gt, gte, lt, lte, ne""" 323 | scores_gt_3 = GameScore.Query.filter(score__gt=3) 324 | self.assertEqual(len(scores_gt_3), 2) 325 | self.assertTrue(all([s.score > 3 for s in scores_gt_3])) 326 | 327 | scores_gte_3 = GameScore.Query.filter(score__gte=3) 328 | self.assertEqual(len(scores_gte_3), 3) 329 | self.assertTrue(all([s.score >= 3 for s in scores_gt_3])) 330 | 331 | scores_lt_4 = GameScore.Query.filter(score__lt=4) 332 | self.assertEqual(len(scores_lt_4), 3) 333 | self.assertTrue(all([s.score < 4 for s in scores_lt_4])) 334 | 335 | scores_lte_4 = GameScore.Query.filter(score__lte=4) 336 | self.assertEqual(len(scores_lte_4), 4) 337 | self.assertTrue(all([s.score <= 4 for s in scores_lte_4])) 338 | 339 | scores_ne_2 = GameScore.Query.filter(score__ne=2) 340 | self.assertEqual(len(scores_ne_2), 4) 341 | self.assertTrue(all([s.score != 2 for s in scores_ne_2])) 342 | 343 | def testChaining(self): 344 | lt_4_gt_2 = GameScore.Query.filter(score__lt=4).filter(score__gt=2) 345 | self.assertEqual(len(lt_4_gt_2), 1, 'chained lt+gt not working') 346 | self.assertEqual(lt_4_gt_2[0].score, 3, 'chained lt+gt not working') 347 | 348 | q = GameScore.Query.filter(score__gt=3, score__lt=3) 349 | self.assertFalse(q.exists(), "chained lt+gt not working") 350 | 351 | # test original queries are idependent after filting 352 | q_all = GameScore.Query.all() 353 | q_special = q_all.filter(score__gt=3) 354 | self.assertEqual(len(q_all), 5) 355 | self.assertEqual(len(q_special), 2) 356 | 357 | q_all = GameScore.Query.all() 358 | q_limit = q_all.limit(1) 359 | self.assertEqual(len(q_all), 5) 360 | self.assertEqual(len(q_limit), 1) 361 | 362 | 363 | def testOrderBy(self): 364 | """test three options- order, limit, and skip""" 365 | scores_ordered = GameScore.Query.all().order_by("score") 366 | self.assertEqual([s.score for s in scores_ordered], [1, 2, 3, 4, 5]) 367 | 368 | scores_ordered_desc = GameScore.Query.all().order_by("score", descending=True) 369 | self.assertEqual([s.score for s in scores_ordered_desc], [5, 4, 3, 2, 1]) 370 | 371 | def testLimit(self): 372 | q = GameScore.Query.all().limit(3) 373 | self.assertEqual(len(q), 3) 374 | 375 | def testSkip(self): 376 | q = GameScore.Query.all().skip(3) 377 | self.assertEqual(len(q), 2) 378 | 379 | def testSelectRelated(self): 380 | score = GameScore.Query.all().select_related('game').limit(1)[0] 381 | self.assertTrue(score.game.objectId) 382 | #nice to have - also check no more then one query is triggered 383 | 384 | def testSelectRelatedArray(self): 385 | scores = GameScore.Query.all() 386 | game = Game.Query.all().limit(1)[0] 387 | 388 | game.score_array = scores 389 | game.save() # Saved an array of pointers in the game 390 | 391 | game = Game.Query.filter(objectId=game.objectId).select_related('score_array')[0] 392 | for score in game.score_array: 393 | self.assertIsInstance(score, GameScore) 394 | self.assertEqual(score.player_name, 'John Doe') # This would trigger a GET if select_related were not used. 395 | #nice to have - also check no more then one query is triggered 396 | 397 | def testCanCompareDateInequality(self): 398 | today = datetime.datetime.today() 399 | tomorrow = today + datetime.timedelta(days=1) 400 | self.assertEqual(GameScore.Query.filter(createdAt__lte=tomorrow).count(), 5, 401 | 'Could not make inequality comparison with dates') 402 | 403 | def testRelations(self): 404 | """Make some maps, make a Game Mode that has many maps, find all maps 405 | given a Game Mode""" 406 | maps = [GameMap(name="map " + i) for i in ['a', 'b', 'c', 'd']] 407 | ParseBatcher().batch_save(maps) 408 | 409 | gm = GameMode(name='test mode') 410 | gm.save() 411 | gm.addRelation("maps", GameMap.__name__, [m.objectId for m in maps]) 412 | 413 | modes = GameMode.Query.all() 414 | self.assertEqual(len(modes), 1) 415 | mode = modes[0] 416 | maps_for_mode = GameMap.Query.filter(maps__relatedTo=mode) 417 | self.assertEqual(len(maps_for_mode), 4) 418 | 419 | gm.delete() 420 | ParseBatcher().batch_delete(maps) 421 | 422 | def testQueryByRelated(self): 423 | game_scores_direct = GameScore.Query.filter(game=self.game) 424 | self.assertTrue(len(game_scores_direct) > 0) 425 | 426 | game_scores_in = GameScore.Query.filter(game__in=[self.game]) 427 | self.assertEqual(len(game_scores_in), len(game_scores_direct)) 428 | 429 | 430 | class TestFunction(unittest.TestCase): 431 | def setUp(self): 432 | '''create and deploy cloud functions''' 433 | original_dir = os.getcwd() 434 | 435 | cloud_function_dir = os.path.join(os.path.split(__file__)[0], 'cloudcode') 436 | os.chdir(cloud_function_dir) 437 | if not os.path.exists("config"): 438 | os.makedirs("config") 439 | if not os.path.exists("public"): 440 | os.makedirs("public") 441 | # write the config file 442 | with open("config/global.json", "w") as outf: 443 | outf.write(GLOBAL_JSON_TEXT % (settings_local.APPLICATION_ID, 444 | settings_local.MASTER_KEY)) 445 | try: 446 | subprocess.call(["parse", "deploy"]) 447 | except OSError as why: 448 | print("parse command line tool must be installed " \ 449 | "(see https://www.parse.com/docs/cloud_code_guide)") 450 | self.skipTest(why) 451 | os.chdir(original_dir) 452 | 453 | def tearDown(self): 454 | ParseBatcher().batch_delete(Review.Query.all()) 455 | 456 | def test_simple_functions(self): 457 | """test hello world and averageStars functions""" 458 | # test the hello function- takes no arguments 459 | 460 | hello_world_func = Function("hello") 461 | ret = hello_world_func() 462 | self.assertEqual(ret["result"], u"Hello world!") 463 | 464 | # Test the averageStars function- takes simple argument 465 | r1 = Review(movie="The Matrix", stars=5, 466 | comment="Too bad they never made any sequels.") 467 | r1.save() 468 | r2 = Review(movie="The Matrix", stars=4, comment="It's OK.") 469 | r2.save() 470 | 471 | star_func = Function("averageStars") 472 | ret = star_func(movie="The Matrix") 473 | self.assertAlmostEqual(ret["result"], 4.5) 474 | 475 | 476 | class TestUser(unittest.TestCase): 477 | USERNAME = "dhelmet@spaceballs.com" 478 | PASSWORD = "12345" 479 | 480 | def _get_user(self): 481 | try: 482 | user = User.signup(self.username, self.password) 483 | except: 484 | user = User.Query.get(username=self.username) 485 | return user 486 | 487 | def _destroy_user(self): 488 | user = self._get_logged_user() 489 | user and user.delete() 490 | 491 | def _get_logged_user(self): 492 | if User.Query.filter(username=self.username).exists(): 493 | return User.login(self.username, self.password) 494 | else: 495 | return self._get_user() 496 | 497 | def setUp(self): 498 | self.username = TestUser.USERNAME 499 | self.password = TestUser.PASSWORD 500 | self.game = None 501 | 502 | try: 503 | u = User.login(self.USERNAME, self.PASSWORD) 504 | except ResourceRequestNotFound: 505 | # if the user doesn't exist, that's fine 506 | return 507 | u.delete() 508 | 509 | def tearDown(self): 510 | self._destroy_user() 511 | if self.game: 512 | self.game.delete() 513 | self.game = None 514 | 515 | def testCanSignUp(self): 516 | self._destroy_user() 517 | user = User.signup(self.username, self.password) 518 | self.assertIsNotNone(user) 519 | self.assertEqual(user.username, self.username) 520 | 521 | def testCanLogin(self): 522 | self._get_user() # User should be created here. 523 | user = User.login(self.username, self.password) 524 | self.assertTrue(user.is_authenticated(), 'Login failed') 525 | 526 | def testCanUpdate(self): 527 | user = self._get_logged_user() 528 | phone_number = '555-5555' 529 | 530 | # add phone number and save 531 | user.phone = phone_number 532 | user.save() 533 | 534 | self.assertTrue(User.Query.filter(phone=phone_number).exists(), 535 | 'Failed to update user data. New info not on Parse') 536 | 537 | def testCanBatchUpdate(self): 538 | user = self._get_logged_user() 539 | phone_number = "555-0134" 540 | 541 | original_updatedAt = user.updatedAt 542 | 543 | user.phone = phone_number 544 | batcher = ParseBatcher() 545 | batcher.batch_save([user]) 546 | 547 | self.assertTrue(User.Query.filter(phone=phone_number).exists(), 548 | 'Failed to batch update user data. New info not on Parse') 549 | self.assertNotEqual(user.updatedAt, original_updatedAt, 550 | 'Failed to batch update user data: updatedAt not changed') 551 | 552 | def testUserAsQueryArg(self): 553 | user = self._get_user() 554 | g = self.game = Game(title='G1', creator=user) 555 | g.save() 556 | self.assertEqual(1, len(Game.Query.filter(creator=user))) 557 | 558 | def testCanGetCurrentUser(self): 559 | user = User.signup(self.username, self.password) 560 | self.assertIsNotNone(user.sessionToken) 561 | 562 | register( 563 | getattr(settings_local, 'APPLICATION_ID'), 564 | getattr(settings_local, 'REST_API_KEY'), 565 | session_token=user.sessionToken 566 | ) 567 | 568 | current_user = User.current_user() 569 | 570 | register( 571 | getattr(settings_local, 'APPLICATION_ID'), 572 | getattr(settings_local, 'REST_API_KEY'), 573 | master_key=getattr(settings_local, 'MASTER_KEY') 574 | ) 575 | 576 | self.assertIsNotNone(current_user) 577 | self.assertEqual(current_user.sessionToken, user.sessionToken) 578 | self.assertEqual(current_user.username, user.username) 579 | 580 | 581 | class TestPush(unittest.TestCase): 582 | """ 583 | Test Push functionality. Currently just sends the messages, ensuring they 584 | don't lead to an error, but does not test whether the messages actually 585 | went through and with the proper attributes (may be worthwhile to 586 | set up such a test). 587 | """ 588 | def testCanMessage(self): 589 | Push.message("Giants beat the Mets.", 590 | channels=["Giants", "Mets"]) 591 | 592 | Push.message("Willie Hayes injured by own pop fly.", 593 | channels=["Giants"], where={"injuryReports": True}) 594 | 595 | Push.message("Giants scored against the A's! It's now 2-2.", 596 | channels=["Giants"], where={"scores": True}) 597 | 598 | def testCanAlert(self): 599 | Push.alert({"alert": "The Mets scored! The game is now tied 1-1.", 600 | "badge": "Increment", "title": "Mets Score"}, 601 | channels=["Mets"], where={"scores": True}) 602 | 603 | 604 | class TestSessionToken(unittest.TestCase): 605 | """ 606 | Test SessionToken class enter and exit. 607 | """ 608 | def get_access_keys(self): 609 | from parse_rest.connection import ACCESS_KEYS 610 | return ACCESS_KEYS 611 | 612 | def testWithSessionToken(self): 613 | with SessionToken(token='asdf'): 614 | self.assertIn('session_token', self.get_access_keys()) 615 | self.assertNotIn('session_token', self.get_access_keys()) 616 | 617 | 618 | class TestMasterKey(unittest.TestCase): 619 | """ 620 | Test MasterKey class enter and exit. 621 | """ 622 | def get_access_keys(self): 623 | from parse_rest.connection import ACCESS_KEYS 624 | return ACCESS_KEYS 625 | 626 | def testWithMasterKey(self): 627 | with MasterKey(master_key='asdf'): 628 | self.assertIn('master_key', self.get_access_keys() ) 629 | self.assertNotIn('master_key', self.get_access_keys() ) 630 | 631 | 632 | def run_tests(): 633 | """Run all tests in the parse_rest package""" 634 | tests = unittest.TestLoader().loadTestsFromNames(['parse_rest.tests']) 635 | t = unittest.TextTestRunner(verbosity=1) 636 | t.run(tests) 637 | 638 | 639 | if __name__ == "__main__": 640 | # command line 641 | unittest.main() 642 | -------------------------------------------------------------------------------- /parse_rest/user.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | 15 | from parse_rest.core import ResourceRequestLoginRequired, ParseError 16 | from parse_rest.connection import API_ROOT 17 | from parse_rest.datatypes import ParseResource, ParseType 18 | from parse_rest.query import QueryManager 19 | 20 | 21 | def login_required(func): 22 | '''decorator describing User methods that need to be logged in''' 23 | def ret(obj, *args, **kw): 24 | if not hasattr(obj, 'sessionToken'): 25 | message = '%s requires a logged-in session' % func.__name__ 26 | raise ResourceRequestLoginRequired(message) 27 | return func(obj, *args, **kw) 28 | return ret 29 | 30 | 31 | class User(ParseResource): 32 | ''' 33 | A User is like a regular Parse object (can be modified and saved) but 34 | it requires additional methods and functionality 35 | ''' 36 | ENDPOINT_ROOT = '/'.join([API_ROOT, 'users']) 37 | PROTECTED_ATTRIBUTES = ParseResource.PROTECTED_ATTRIBUTES + [ 38 | 'username', 'sessionToken', 'emailVerified'] 39 | 40 | def is_authenticated(self): 41 | return self.sessionToken is not None 42 | 43 | def authenticate(self, password=None, session_token=None): 44 | if self.is_authenticated(): return 45 | 46 | if password is not None: 47 | self = User.login(self.username, password) 48 | 49 | user = User.Query.get(objectId=self.objectId) 50 | if user.objectId == self.objectId and user.sessionToken == session_token: 51 | self.sessionToken = session_token 52 | 53 | @login_required 54 | def session_header(self): 55 | return {'X-Parse-Session-Token': self.sessionToken} 56 | 57 | @login_required 58 | def save(self, batch=False): 59 | session_header = {'X-Parse-Session-Token': self.sessionToken} 60 | url = self._absolute_url 61 | data = self._to_native() 62 | 63 | response = User.PUT(url, extra_headers=session_header, batch=batch, **data) 64 | 65 | def call_back(response_dict): 66 | self.updatedAt = response_dict['updatedAt'] 67 | 68 | if batch: 69 | return response, call_back 70 | else: 71 | call_back(response) 72 | 73 | @login_required 74 | def delete(self): 75 | session_header = {'X-Parse-Session-Token': self.sessionToken} 76 | return User.DELETE(self._absolute_url, extra_headers=session_header) 77 | 78 | @login_required 79 | def logout(self): 80 | logout_url = '/'.join([API_ROOT, 'logout']) 81 | session_header = {'X-Parse-Session-Token': self.sessionToken} 82 | return User.POST(logout_url, extra_headers=session_header) 83 | 84 | @classmethod 85 | def signup(cls, username, password, **kw): 86 | response_data = User.POST('', username=username, password=password, **kw) 87 | response_data.update({'username': username}) 88 | return cls(**response_data) 89 | 90 | @classmethod 91 | def login(cls, username, passwd): 92 | login_url = '/'.join([API_ROOT, 'login']) 93 | return cls(**User.GET(login_url, username=username, password=passwd)) 94 | 95 | @classmethod 96 | def login_auth(cls, auth): 97 | login_url = User.ENDPOINT_ROOT 98 | return cls(**User.POST(login_url, authData=auth)) 99 | 100 | @classmethod 101 | def current_user(cls): 102 | user_url = '/'.join([API_ROOT, 'users/me']) 103 | return cls(**User.GET(user_url)) 104 | 105 | @staticmethod 106 | def request_password_reset(email): 107 | '''Trigger Parse\'s Password Process. Return True/False 108 | indicate success/failure on the request''' 109 | 110 | url = '/'.join([API_ROOT, 'requestPasswordReset']) 111 | try: 112 | User.POST(url, email=email) 113 | return True 114 | except ParseError: 115 | return False 116 | 117 | def _to_native(self): 118 | return dict([(k, ParseType.convert_to_parse(v, as_pointer=True)) 119 | for k, v in self._editable_attrs.items()]) 120 | 121 | @property 122 | def className(self): 123 | return '_User' 124 | 125 | def __repr__(self): 126 | return '' % (getattr(self, 'username', None), self.objectId) 127 | 128 | def removeRelation(self, key, className, objectsId): 129 | self.manageRelation('RemoveRelation', key, className, objectsId) 130 | 131 | def addRelation(self, key, className, objectsId): 132 | self.manageRelation('AddRelation', key, className, objectsId) 133 | 134 | def manageRelation(self, action, key, className, objectsId): 135 | objects = [{ 136 | "__type": "Pointer", 137 | "className": className, 138 | "objectId": objectId 139 | } for objectId in objectsId] 140 | 141 | payload = { 142 | key: { 143 | "__op": action, 144 | "objects": objects 145 | } 146 | } 147 | self.__class__.PUT(self._absolute_url, **payload) 148 | 149 | # Commented this line out to fix the saving relation issue -- 150 | # it's better to just return None in Relation._to_native 151 | # self.__dict__[key] = '' 152 | 153 | def relation(self, key): 154 | if not hasattr(self, key): 155 | return Relation(parentObject=self, key=key) 156 | try: 157 | return getattr(self, key).with_parent(parentObject=self, key=key) 158 | except: 159 | raise ParseError("Column '%s' is not a Relation." % (key,)) 160 | 161 | 162 | 163 | User.Query = QueryManager(User) 164 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, Command 3 | from unittest import TextTestRunner, TestLoader 4 | 5 | 6 | class TestCommand(Command): 7 | '''Run test suite using `python setup.py test `''' 8 | user_options = [] 9 | 10 | def initialize_options(self): 11 | pass 12 | 13 | def finalize_options(self): 14 | pass 15 | 16 | def run(self): 17 | '''Run test suite in parse_rest.tests''' 18 | tests = TestLoader().loadTestsFromNames(['parse_rest.tests']) 19 | t = TextTestRunner(verbosity=1) 20 | t.run(tests) 21 | 22 | 23 | setup( 24 | name='parse_rest', 25 | version='0.2.20170114', 26 | description='A client library for Parse.com\'.s REST API', 27 | url='https://github.com/milesrichardson/ParsePy', 28 | packages=['parse_rest'], 29 | package_data={"parse_rest": [os.path.join("cloudcode", "*", "*")]}, 30 | install_requires=['six'], 31 | maintainer='Miles Richardson', 32 | maintainer_email='miles.richardson@gmail.com', 33 | cmdclass={'test': TestCommand}, 34 | classifiers=[ 35 | 'Development Status :: 4 - Beta', 36 | 'Environment :: Web Environment', 37 | 'Intended Audience :: Developers', 38 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 39 | 'Operating System :: OS Independent', 40 | "Programming Language :: Python :: 2.6", 41 | "Programming Language :: Python :: 2.7", 42 | "Programming Language :: Python :: 3.3", 43 | "Programming Language :: Python :: 3.4", 44 | ] 45 | ) 46 | --------------------------------------------------------------------------------