├── test ├── __init__.py ├── test_bwproject.py ├── test_credentials.py └── test_id_name_map.py ├── MANIFEST.in ├── src └── bwapi │ ├── __init__.py │ ├── authenticate.py │ ├── credentials.py │ ├── filters.py │ ├── bwproject.py │ ├── bwdata.py │ └── bwresources.py ├── Pipfile ├── .gitignore ├── setup.cfg ├── LICENSE.md ├── setup.py ├── CHANGELOG.md ├── README.md ├── Makefile └── DEMO.ipynb /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | 4 | recursive-include tests * 5 | recursive-exclude * __pycache__ 6 | recursive-exclude * *.py[co] 7 | 8 | recursive-include src * 9 | -------------------------------------------------------------------------------- /src/bwapi/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for bwapi.""" 2 | from warnings import warn 3 | 4 | __version__ = "4.1.0" 5 | 6 | warn( 7 | "The bwapi package is deprecated. Please use 'bcr-api' instead: " 8 | "https://github.com/BrandwatchLtd/bcr-api", 9 | DeprecationWarning, 10 | stacklevel=2, 11 | ) 12 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | flake8 = "*" 8 | pylint = "*" 9 | twine = "*" 10 | pytest = "*" 11 | responses = "*" 12 | bumpversion = "*" 13 | coverage = "*" 14 | pydocstyle = "*" 15 | better-exceptions = "*" 16 | jupyterlab = "*" 17 | black = "==19.3b0" 18 | pytest-cov = "*" 19 | pytest-sugar = "*" 20 | 21 | [packages] 22 | bwapi = {editable = true,path = "."} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | /venv/ 4 | /.idea/ 5 | 6 | tokens.txt 7 | 8 | .Python 9 | env/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | .mypy_cache/ 25 | Pipfile.lock 26 | .ipynb_checkpoints/ 27 | 28 | # Unit test / coverage reports 29 | htmlcov/ 30 | .tox/ 31 | .coverage 32 | .coverage.* 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | *,cover 37 | .hypothesis/ -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 4.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version="{current_version}" 8 | replace = version="{new_version}" 9 | 10 | [bumpversion:file:src/bwapi/__init__.py] 11 | search = __version__ = "{current_version}" 12 | replace = __version__ = "{new_version}" 13 | 14 | [metadata] 15 | license_file = LICENSE.md 16 | 17 | [flake8] 18 | max-line-length = 88 19 | ignore = E501,W503 20 | 21 | [aliases] 22 | test = pytest 23 | 24 | [tool:pytest] 25 | addopts = --verbose 26 | 27 | [bdist_wheel] 28 | python-tag = py35 29 | 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 BrandwatchLtd 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | requirements = ["requests>=2.22.0"] 4 | 5 | setup_requirements = ["pytest-runner", "setuptools>=38.6.0", "wheel>=0.31.0"] 6 | 7 | test_requirements = ["pytest", "responses"] 8 | 9 | with open("README.md") as infile: 10 | long_description = infile.read() 11 | 12 | 13 | setup( 14 | name="bwapi", 15 | version="4.1.0", 16 | description="A software development kit for the Brandwatch API", 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | url="https://github.com/BrandwatchLtd/api_sdk", 20 | author="Amy Barker, Jamie Lebovics, Paul Siegel and Jessica Bowden", 21 | author_email="amyb@brandwatch.com, paul@brandwatch.com, jess@brandwatch.com", 22 | license="License :: OSI Approved :: MIT License", 23 | classifiers=[ 24 | "Development Status :: 5 - Production/Stable", 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: MIT License", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3.5", 29 | "Programming Language :: Python :: 3.6", 30 | "Programming Language :: Python :: 3.7", 31 | "Programming Language :: Python :: 3.8", 32 | ], 33 | packages=find_packages(where="src", include=["bwapi"]), 34 | package_dir={"": "src"}, 35 | entry_points={"console_scripts": ["bwapi-authenticate = bwapi.authenticate:main"]}, 36 | install_requires=requirements, 37 | tests_require=test_requirements, 38 | setup_requires=setup_requirements, 39 | python_requires=">=3.5", 40 | test_suite="tests", 41 | ) 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [4.0.2] - 2019-08-27 5 | ### Changed 6 | * Changed BWResources self.id mapping (where resource names are keys and ids are values) to self.names (where ids are keys and names are values). Made a number of changes that follow from this. 7 | 8 | ## [4.0.1] - 2019-04-05 9 | ### Changed 10 | * Added project ID to rules that are being uploaded, in accordance with a change to the Brandwatch API. 11 | 12 | ## [4.0.0] - 2019-01-23 13 | ### Changed 14 | * **Moduel Imports** - All modules (e.g bwproject, bwresources, etc...) have been moved into a single package called `bwapi`. This helps to keep things organised and avoid collisions with any other packages you happen to using. All code dependent on bwapi will need to be updated, such that modules are imported from the `bwapi` package. See the following examples for more specific guidance: 15 | ```python 16 | from bwproject import BWProject, ... 17 | from bwresources import BWQueries, BWGroups, ... 18 | from bwdata import BWData ... 19 | ``` 20 | must be updated to 21 | ```python 22 | from bwapi.bwproject import BWProject, ... 23 | from bwapi.bwresources import BWQueries, BWGroups, ... 24 | from bwapi.bwdata import BWData ... 25 | ``` 26 | Importing modules can be changed from 27 | ```python 28 | import bwproject 29 | ``` 30 | to the following without breaking existing functionality 31 | ```python 32 | import bwapi.bwproject as bwproject 33 | ``` 34 | * **Authentication** - `authenticate.py` script changed to command line program `bwapi-authenticate` added to the PATH 35 | ```bash 36 | $ ./authenticate.py 37 | ``` 38 | changed to 39 | ```bash 40 | $ bwapi-authenticate 41 | Please enter your Brandwatch credentials below 42 | Username: example@example 43 | Password: 44 | Authenticating user: example@example 45 | Writing access token for user: example@example 46 | Writing access token for user: example@example 47 | Success! Access token: 00000000-0000-0000-0000-000000000000 48 | ``` -------------------------------------------------------------------------------- /src/bwapi/authenticate.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | from getpass import getpass 4 | import logging 5 | from pathlib import Path 6 | 7 | from . import credentials 8 | from .bwproject import BWUser 9 | 10 | import argparse 11 | 12 | 13 | def authenticate(username, password, credentials_path=None): 14 | """ 15 | Authenticate the given username and password pair, storing the access token in the credentials file. 16 | 17 | :param username: Brandwatch account usernames 18 | :param password: Brandwatch account password 19 | :param credentials_path: Path to where credentials should be stored 20 | :return: An authenticated BWUser object 21 | """ 22 | return BWUser(username=username, password=password, token_path=credentials_path) 23 | 24 | 25 | def main(): 26 | logger = logging.getLogger("bwapi") 27 | logger.setLevel(logging.INFO) 28 | handler = logging.StreamHandler() 29 | formatter = logging.Formatter("%(levelname)s: %(message)s", "%H:%M:%S") 30 | handler.setFormatter(formatter) 31 | logger.addHandler(handler) 32 | 33 | parser = argparse.ArgumentParser( 34 | description="Logging to Brandwatch and retrieve and access token.", 35 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 36 | ) 37 | 38 | parser.add_argument( 39 | "--store", 40 | "-s", 41 | type=Path, 42 | metavar="PATH", 43 | default=credentials.DEFAULT_CREDENTIALS_PATH, 44 | help="Path to where access tokens are stored.", 45 | ) 46 | 47 | parser.add_argument( 48 | "--username", 49 | "-u", 50 | type=str, 51 | help="Brandwatch username (probably your email address).", 52 | ) 53 | parser.add_argument("--password", "-p", type=str, help="Brandwatch password.") 54 | 55 | args = parser.parse_args() 56 | 57 | if args.username is None or args.password is None: 58 | print("Please enter your Brandwatch credentials below") 59 | if args.username is None: 60 | args.username = input("Username: ") 61 | if args.password is None: 62 | args.password = getpass("Password: ") 63 | 64 | try: 65 | print("Authenticating user: {}".format(args.username)) 66 | user = authenticate(args.username, args.password, credentials_path=args.store) 67 | print("Success! Access token: {}".format(user.token)) 68 | except KeyError as e: 69 | print(e) 70 | 71 | 72 | if __name__ == "__main__": 73 | main() 74 | -------------------------------------------------------------------------------- /test/test_bwproject.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import responses 3 | import os 4 | import tempfile 5 | 6 | from bwapi.bwproject import BWProject 7 | 8 | 9 | class TestBWProjectUsernameCaseSensitivity(unittest.TestCase): 10 | 11 | USERNAME = "example@Example.com" 12 | ACCESS_TOKEN = "00000000-0000-0000-0000-000000000000" 13 | PROJECT_NAME = "Example project" 14 | PROJECTS = [ 15 | { 16 | "id": 0, 17 | "name": PROJECT_NAME, 18 | "description": "", 19 | "billableClientId": 0, 20 | "billableClientName": "My company", 21 | "timezone": "Africa/Abidjan", 22 | "billableClientIsPitch": False, 23 | } 24 | ] 25 | 26 | def setUp(self): 27 | self.token_path = tempfile.NamedTemporaryFile(suffix="-tokens.txt").name 28 | 29 | responses.add( 30 | responses.GET, 31 | "https://api.brandwatch.com/projects", 32 | json={ 33 | "resultsTotal": len(self.PROJECTS), 34 | "resultsPage": -1, 35 | "resultsPageSize": -1, 36 | "results": self.PROJECTS, 37 | }, 38 | status=200, 39 | ) 40 | 41 | responses.add( 42 | responses.POST, 43 | "https://api.brandwatch.com/oauth/token", 44 | json={"access_token": self.ACCESS_TOKEN}, 45 | status=200, 46 | ) 47 | 48 | def tearDown(self): 49 | os.unlink(self.token_path) 50 | responses.reset() 51 | 52 | @responses.activate 53 | def test_lowercase_username(self): 54 | self._test_username("example@example.com") 55 | 56 | @responses.activate 57 | def test_uppercase_username(self): 58 | self._test_username("EXAMPLE@EXAMPLE.COM") 59 | 60 | @responses.activate 61 | def test_mixedcase_username(self): 62 | self._test_username("eXaMpLe@ExAmPlE.cOm") 63 | 64 | def _test_username(self, username): 65 | 66 | responses.add( 67 | responses.GET, 68 | "https://api.brandwatch.com/me", 69 | json={"username": username}, 70 | status=200, 71 | ) 72 | 73 | BWProject( 74 | username=username, 75 | project=self.PROJECT_NAME, 76 | password="", 77 | token_path=self.token_path, 78 | ) 79 | try: 80 | BWProject( 81 | username=username, project=self.PROJECT_NAME, token_path=self.token_path 82 | ) 83 | except KeyError as e: 84 | self.fail(e) 85 | 86 | 87 | if __name__ == "__main__": 88 | unittest.main() 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/BrandwatchLtd/api_sdk.svg?branch=master)](https://travis-ci.com/BrandwatchLtd/api_sdk) 2 | 3 | [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 4 | 5 | # Brandwatch API SDK (Deprecated) 6 | 7 | ## Deprecation note 8 | 9 | This library is intended for use with the Brandwatch Analytics API. Brandwatch Analytics has been succeeded by Brandwatch Consumer Research, which has its own [Python API client library](https://github.com/BrandwatchLtd/bcr-api). Please use that library for working with Brandwatch Consumer Research. 10 | 11 | As Brandwatch Analytics is no longer in use, this library is no longer being maintained. 12 | 13 | ## Introduction 14 | 15 | The Brandwatch API SDK was designed to address many of the challenges involved in building complex applications which interact with RESTful API's in general and Brandwatch's API from Python 3, in particular: 16 | 17 | - The SDK's object hierarchy roughly mirrors the API's resource hierarchy, making the code intuitive for those familiar with the Brandwatch platform 18 | - All required parameters are enforced, and most optional parameters are supported and documented 19 | - Typical Brandwatch workflows are supported behind the scenes; for instance, one can validate, upload, and backfill a query with a single function call 20 | - The SDK is designed to support simple and readable code: sensible defaults are chosen for rarely used parameters and all resource IDs are handled behind the scenes 21 | 22 | From the user's perspective, the basic structure of the SDK is as follows. One first creates an instance of the class `BWProject`; this class handles authentication (via a user name and password or API key) and keeps track of project-level data such as the project's ID. (Behind the scenes, the user-level operations are handled by the class `BWUser` from which `BWProject` is inherited.) One passes `BWProject` instance as an argument in the constructor for a series of classes which manage the various Brandwatch resources: queries, groups, tags, categories, etc. These resource classes manage all resource-level operations: for example a single `BWQueries` instance handles all HTTP requests associated with queries in its attached project. 23 | 24 | ## Installation 25 | 26 | Be sure to install the latest version of Python 3.x. You can install bwapi on your machine by running the following command: 27 | 28 | `pip install bwapi` 29 | 30 | This allows you to run scripts that import bwproject or bwresources from anywhere on your computer. 31 | 32 | ## Examples 33 | 34 | Please see the Jupyter notebook DEMO.ipynb for examples. This notebook was built as a beginner's guide to using the Brandwatch API SDK, so it has example code, as well as detailed instructions for use. 35 | 36 | ## Disclaimer 37 | 38 | This is not an official or supported Brandwatch library, and should be implemented at the users' own risk. 39 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build test lint coverage upload dist bumpversion-patch bumpversion-minor bumpversion-major install install-dev 2 | 3 | ## remove all build, test, coverage and Python artifacts 4 | clean: clean-build clean-pyc clean-test 5 | 6 | ## remove build artifacts 7 | clean-build: 8 | rm -fr build/ 9 | rm -fr dist/ 10 | rm -fr .eggs/ 11 | find . -name '*.egg' -exec rm -f {} + 12 | 13 | ## remove Python file artifacts 14 | clean-pyc: 15 | find . -name '*.pyc' -exec rm -f {} + 16 | find . -name '*.pyo' -exec rm -f {} + 17 | find . -name '*~' -exec rm -f {} + 18 | find . -name '__pycache__' -exec rm -fr {} + 19 | rm -rf .mypy_cache/ 20 | 21 | ## remove test and coverage artifacts 22 | clean-test: 23 | rm -fr .tox/ 24 | rm -f .coverage 25 | rm -fr htmlcov/ 26 | rm -fr .cache/ 27 | rm -fr .pytest_cache 28 | 29 | ## check style with flake8, black 30 | lint: clean 31 | pipenv run black . --check 32 | pipenv run flake8 bwapi tests --exit-zero 33 | 34 | ## run tests with the default Python 35 | test: lint 36 | pipenv run pytest -vv --cov=bwapi 37 | 38 | ## check code coverage quickly with the default Python 39 | coverage: clean 40 | pipenv run pytest -vv --cov=bwapi --cov-report html --cov-report term 41 | @echo '✨ 🍰 ✨ Open "htmlcov/index.html" in your browser to view report' 42 | ## upload wheel 43 | upload: dist 44 | pipenv run twine upload -r pypi dist/bwapi* 45 | 46 | ## increment the patch version, and tag in git 47 | bumpversion-patch: clean 48 | pipenv run bumpversion --verbose patch 49 | 50 | ## increment the minor version, and tag in git 51 | bumpversion-minor: clean 52 | pipenv run bumpversion --verbose minor 53 | 54 | ## increment the major version, and tag in git 55 | bumpversion-major: clean 56 | pipenv run bumpversion --verbose major 57 | 58 | ## builds source and wheel package 59 | dist: clean 60 | pipenv run python setup.py sdist bdist_wheel 61 | 62 | ## install the package to the pipenv virtualenv 63 | install: clean 64 | pipenv install 65 | 66 | ## install the package and all development dependencies to the pipenv virtualenv 67 | install-dev: clean 68 | pipenv install --dev 69 | 70 | ############################################################################## 71 | # Self Documenting Commands # 72 | ############################################################################## 73 | .DEFAULT_GOAL := show-help 74 | # See for explanation. 75 | .PHONY: show-help 76 | show-help: 77 | @echo "$$(tput bold)Available rules:$$(tput sgr0)";echo;sed -ne"/^## /{h;s/.*//;:d" -e"H;n;s/^## //;td" -e"s/:.*//;G;s/\\n## /---/;s/\\n/ /g;p;}" ${MAKEFILE_LIST}|LC_ALL='C' sort -f|awk -F --- -v n=$$(tput cols) -v i=19 -v a="$$(tput setaf 6)" -v z="$$(tput sgr0)" '{printf"%s%*s%s ",a,-i,$$1,z;m=split($$2,w," ");l=n-i;for(j=1;j<=m;j++){l-=length(w[j])+1;if(l<= 0){l=n-i-length(w[j])-1;printf"\n%*s ",-i," ";}printf"%s ",w[j];}printf"\n";}' -------------------------------------------------------------------------------- /src/bwapi/credentials.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | credentials contains the CredentialsStore class, which responsible for persisting access tokens to disk. 4 | """ 5 | 6 | import logging 7 | import os 8 | from pathlib import Path 9 | 10 | DEFAULT_CREDENTIALS_PATH = Path(os.path.expanduser("~")) / ".bwapi" / "credentials.txt" 11 | 12 | logger = logging.getLogger("bwapi") 13 | 14 | 15 | class CredentialsStore: 16 | """ 17 | CredentialsStore is responsible for persisting access tokens to disk. 18 | """ 19 | 20 | def __init__(self, credentials_path=None): 21 | """ 22 | Create a new CredentialsStore 23 | 24 | :param credentials_path: Path to the credentials file 25 | """ 26 | if credentials_path is None: 27 | credentials_path = DEFAULT_CREDENTIALS_PATH 28 | self._credentials_path = Path(credentials_path) 29 | 30 | def __getitem__(self, username): 31 | """ Get self[username] """ 32 | user_tokens = self._read() 33 | return user_tokens[username.lower()] 34 | 35 | def __setitem__(self, username, token): 36 | """ Set self[username] to access token. """ 37 | credentials = self._read() 38 | if username.lower() in credentials: 39 | if credentials[username.lower()] == token: 40 | return 41 | else: 42 | logger.info( 43 | "Overwriting access token for %s in %s", 44 | username, 45 | self._credentials_path, 46 | ) 47 | else: 48 | logger.info("Writing access token for user: %s", username) 49 | credentials[username.lower()] = token 50 | self._write(credentials) 51 | 52 | def __delitem__(self, username): 53 | """ Delete self[username]. """ 54 | credentials = self._read() 55 | if username.lower() in credentials: 56 | logger.info("Deleting access token for user: %s", username) 57 | del credentials[username.lower()] 58 | self._write(credentials) 59 | 60 | def __iter__(self): 61 | """ Implement iter(self). """ 62 | credentials = self._read() 63 | yield from credentials.items() 64 | 65 | def __len__(self): 66 | return len(self._read()) 67 | 68 | def _write(self, credentials): 69 | self._ensure_file_exists() 70 | with open(str(self._credentials_path), "w") as token_file: 71 | contents = "\n".join(["\t".join(item) for item in credentials.items()]) 72 | token_file.write(contents) 73 | 74 | def _read(self): 75 | self._ensure_file_exists() 76 | with open(str(self._credentials_path)) as token_file: 77 | credentials = dict() 78 | for line in token_file: 79 | try: 80 | user, token = line.split() 81 | except ValueError: 82 | logger.warning('Ignoring corrupted credentials line: "%s"', line) 83 | pass 84 | credentials[user.lower()] = token 85 | return credentials 86 | 87 | def _ensure_file_exists(self): 88 | self._ensure_dir_exists() 89 | if not self._credentials_path.exists(): 90 | logger.debug("Creating credentials store: %s", self._credentials_path) 91 | self._credentials_path.touch(mode=0o600) 92 | 93 | def _ensure_dir_exists(self): 94 | if not self._credentials_path.parent.exists(): 95 | logger.debug( 96 | "Creating credentials store parent directory: %s", 97 | self._credentials_path.parent, 98 | ) 99 | self._credentials_path.parent.mkdir(parents=True, mode=0o755) 100 | -------------------------------------------------------------------------------- /test/test_credentials.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import tempfile 3 | import unittest 4 | from pathlib import Path 5 | 6 | from bwapi.credentials import CredentialsStore 7 | 8 | ACCESS_TOKEN = "00000000-0000-0000-0000-000000000000" 9 | 10 | 11 | class TestCredentialsStore(unittest.TestCase): 12 | def with_credential_store(function): 13 | def wrapper(self): 14 | with tempfile.TemporaryDirectory() as temp_dir: 15 | token_path = Path(temp_dir) / "tokens.txt" 16 | store = CredentialsStore(credentials_path=token_path) 17 | function(self, store) 18 | 19 | return wrapper 20 | 21 | @with_credential_store 22 | def test_file_created_on_read(self, store): 23 | self.assertFalse(store._credentials_path.exists()) 24 | 25 | _ = [c for c in store] 26 | 27 | self.assertTrue(store._credentials_path.exists()) 28 | 29 | @with_credential_store 30 | def test_file_created_on_write(self, store): 31 | self.assertFalse(store._credentials_path.exists()) 32 | 33 | store["example@example.com"] = ACCESS_TOKEN 34 | 35 | self.assertTrue(store._credentials_path.exists()) 36 | 37 | @with_credential_store 38 | def test_store(self, store): 39 | self.assertEqual(len(store), 0) 40 | 41 | store["example@example.com"] = ACCESS_TOKEN 42 | 43 | self.assertEqual(store["example@example.com"], ACCESS_TOKEN) 44 | self.assertEqual(len(store), 1) 45 | 46 | @with_credential_store 47 | def test_store_multiple(self, store): 48 | self.assertEqual(len(store), 0) 49 | 50 | store["example@example.com"] = "10000000-0000-0000-0000-000000000000" 51 | store["another-example@example.com"] = "20000000-0000-0000-0000-000000000000" 52 | 53 | self.assertEqual( 54 | store["example@example.com"], "10000000-0000-0000-0000-000000000000" 55 | ) 56 | self.assertEqual( 57 | store["another-example@example.com"], "20000000-0000-0000-0000-000000000000" 58 | ) 59 | self.assertEqual(len(store), 2) 60 | 61 | @with_credential_store 62 | def test_store_overwrite(self, store): 63 | self.assertEqual(len(store), 0) 64 | 65 | store["example@example.com"] = "10000000-0000-0000-0000-000000000000" 66 | store["example@example.com"] = "20000000-0000-0000-0000-000000000000" 67 | 68 | self.assertEqual( 69 | store["example@example.com"], "20000000-0000-0000-0000-000000000000" 70 | ) 71 | 72 | @with_credential_store 73 | def test_store_same(self, store): 74 | self.assertEqual(len(store), 0) 75 | 76 | store["example@example.com"] = ACCESS_TOKEN 77 | store["example@example.com"] = ACCESS_TOKEN 78 | 79 | self.assertEqual(store["example@example.com"], ACCESS_TOKEN) 80 | 81 | @with_credential_store 82 | def test_store_case_insensitive(self, store): 83 | store["example@example.com"] = ACCESS_TOKEN 84 | store["EXAMPLE@EXAMPLE.COM"] = ACCESS_TOKEN 85 | store["eXaMpLe@ExAmPlE.cOm"] = ACCESS_TOKEN 86 | self.assertEqual(len(store), 1) 87 | 88 | @with_credential_store 89 | def test_store_lower(self, store): 90 | store["example@example.com"] = ACCESS_TOKEN 91 | self.assertEqual(store["example@example.com"], ACCESS_TOKEN) 92 | 93 | @with_credential_store 94 | def test_store_upper(self, store): 95 | store["EXAMPLE@EXAMPLE.COM"] = ACCESS_TOKEN 96 | self.assertEqual(store["example@example.com"], ACCESS_TOKEN) 97 | 98 | @with_credential_store 99 | def test_store_mixed(self, store): 100 | store["eXaMpLe@ExAmPlE.cOm"] = ACCESS_TOKEN 101 | self.assertEqual(store["example@example.com"], ACCESS_TOKEN) 102 | 103 | @with_credential_store 104 | def test_get_lower(self, store): 105 | store["example@example.com"] = ACCESS_TOKEN 106 | self.assertEqual(store["example@example.com"], ACCESS_TOKEN) 107 | 108 | @with_credential_store 109 | def test_get_upper(self, store): 110 | store["example@example.com"] = ACCESS_TOKEN 111 | self.assertEqual(store["EXAMPLE@EXAMPLE.COM"], ACCESS_TOKEN) 112 | 113 | @with_credential_store 114 | def test_get_mixed(self, store): 115 | store["example@example.com"] = ACCESS_TOKEN 116 | self.assertEqual(store["eXaMpLe@ExAmPlE.cOm"], ACCESS_TOKEN) 117 | -------------------------------------------------------------------------------- /test/test_id_name_map.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from bwapi.bwresources import BWQueries 4 | 5 | query_id = 1111111111 6 | 7 | 8 | class StubBWProject: 9 | """Stub equivalent of BWProject, which can return enough canned responses to create an instance of BWQueries 10 | Also contains a canned response to allow BWQueries' get() method to be called and get info about a specific query""" 11 | 12 | def __init__( 13 | self, project="MyProject", username="user@example.com", password="mypassword" 14 | ): 15 | self.project = project 16 | self.username = username 17 | self.password = password 18 | self.examples = { 19 | "queries": { 20 | "resultsTotal": 1, 21 | "resultsPage": -1, 22 | "resultsPageSize": -1, 23 | "results": [ 24 | { 25 | "id": query_id, 26 | "name": "My Query", 27 | "description": None, 28 | "creationDate": "2019-01-01T00:00:00.000+0000", 29 | "lastModificationDate": "2019-01-02T00:00:00.000+0000", 30 | "industry": "general-(recommended)", 31 | "includedTerms": ["My Query String"], 32 | "languages": ["en"], 33 | "twitterLimit": 1500, 34 | "dailyLimit": 10000, 35 | "type": "search string", 36 | "twitterScreenName": None, 37 | "highlightTerms": ["my", "query", "string"], 38 | "samplePercent": 100, 39 | "lastModifiedUsername": "user@example.com", 40 | "languageAgnostic": False, 41 | "lockedQuery": False, 42 | "lockedByUsername": None, 43 | "lockedTime": None, 44 | "createdByWizard": False, 45 | "unlimitedHistoricalData": { 46 | "backfillMinDate": "2019-01-01T00:00:00.000+0000", 47 | "unlimitedHistoricalDataEnabled": False, 48 | }, 49 | } 50 | ], 51 | }, 52 | "tags": { 53 | "resultsTotal": -1, 54 | "resultsPage": -1, 55 | "resultsPageSize": -1, 56 | "results": [], 57 | }, 58 | "categories": { 59 | "resultsTotal": -1, 60 | "resultsPage": -1, 61 | "resultsPageSize": -1, 62 | "results": [], 63 | }, 64 | } 65 | self.examples["specific_query"] = self.examples["queries"]["results"][0] 66 | self.apiurl = "https://api.brandwatch.com/" 67 | self.token = 2222222222 68 | 69 | def get(self, endpoint, params={}): 70 | """get without the need for responses library to be used""" 71 | if endpoint in ["queries", "tags", "categories"]: 72 | return self.examples[endpoint] 73 | elif endpoint.startswith("queries/"): # e.g. the call is for queries/query_id 74 | return self.examples["specific_query"] 75 | else: 76 | print(endpoint) 77 | raise NotImplementedError 78 | 79 | 80 | class TestBWQueriesCreation(unittest.TestCase): 81 | """ 82 | Used to run tests on BWQueries, using the real BWQueries class, but the stubbed version of BWProject 83 | """ 84 | 85 | def __init__(self, *args, **kwargs): 86 | unittest.TestCase.__init__( 87 | self, *args, **kwargs 88 | ) # prevent this __init__ from overriding unittest testcase's original __init__ 89 | self.project = StubBWProject() 90 | self.queries = self.test_create_queries() 91 | 92 | def test_create_queries(self): 93 | test_queries = BWQueries(self.project) 94 | return test_queries 95 | 96 | def test_query_get_provide_string(self): 97 | return self.queries.get("My Query") 98 | 99 | def test_query_get_provide_id(self): 100 | """ 101 | this is the function that before the fix would return TypeError: must be str, not int 102 | """ 103 | return self.queries.get(query_id) 104 | 105 | def test_query_id_get_equal(self): 106 | actual = self.test_query_get_provide_string() 107 | expected = self.test_query_get_provide_id() 108 | self.assertEqual(actual, expected) 109 | 110 | def test_query_get_provide_None(self): 111 | """ 112 | can a user pass nothing into queries.get() 113 | """ 114 | actual = self.queries.get() 115 | expected = self.project.examples["queries"]["results"][0] 116 | self.assertEqual(actual, expected) 117 | 118 | 119 | if __name__ == "__main__": 120 | unittest.main() 121 | -------------------------------------------------------------------------------- /src/bwapi/filters.py: -------------------------------------------------------------------------------- 1 | """ all filters and the data types of their input """ 2 | # used with get_mentions() in queries/groups and with filters in rules 3 | params = { 4 | "author": str, 5 | "xauthor": str, 6 | "exactAuthor": str, 7 | "xexactAuthor": str, 8 | "authorGroup": list, # user passes in a string which gets converted to a list of ids 9 | "xauthorGroup": list, # user passes in a string which gets converted to a list of ids 10 | "blogCommentsMin": int, 11 | "blogCommentsMax": int, 12 | "category": list, 13 | # user passes in a dictionary {parent:[child1, child2, etc]} which gets converted to a list of ids 14 | "xcategory": list, 15 | # user passes in a dictionary {parent:[child, child2, etc]} which gets converted to a list of ids 16 | "parentCategory": list, # user passes in a string which gets converted to a list of ids 17 | "xparentCategory": list, # user passes in a string which gets converted to a list of ids 18 | "facebookAuthorId": int, 19 | "xfacebookAuthorId": int, 20 | "facebookRole": str, 21 | "xfacebookRole": str, 22 | "facebookSubtype": str, 23 | "xfacebookSubtype": str, 24 | "facebookCommentsMin": int, 25 | "facebookCommentsMax": int, 26 | "facebookLikesMin": int, 27 | "facebookLikesMax": int, 28 | "facebookSharesMin": int, 29 | "facebookSharesMax": int, 30 | "resourceType": str, 31 | "xresourceType": str, 32 | "forumPostsMin": int, 33 | "forumPostsMax": int, 34 | "forumViewsMin": int, 35 | "forumViewsMax": int, 36 | "impactMin": int, 37 | "impactMax": int, 38 | "language": str, 39 | "xlanguage": str, 40 | "locationGroup": list, # user passes in a string which gets converted to a list of ids 41 | "xlocationGroup": list, # user passes in a string which gets converted to a list of ids 42 | "location": str, 43 | "xlocation": str, 44 | "starred": bool, 45 | "search": str, 46 | "pageType": (str, list), 47 | "xpageType": (str, list), 48 | "sentiment": str, 49 | "siteGroup": list, # user passes in a string which gets converted to a list of ids 50 | "xsiteGroup": list, # user passes in a string which gets converted to a list of ids 51 | "backlinksMin": int, 52 | "backlinksMax": int, 53 | "mozRankMin": int, 54 | "mozRankMax": int, 55 | "domain": str, 56 | "xdomain": str, 57 | "pagesPerVisitMin": int, 58 | "pagesPerVisitMax": int, 59 | "averageVisitsMin": int, 60 | "averageVisitsMax": int, 61 | "monthlyVisitorsMin": int, 62 | "monthlyVisitorsMax": int, 63 | "percentFemaleVisitorsMin": int, 64 | "percentMaleVisitorsMin": int, 65 | "averageDurationOfVisitMin": int, 66 | "averageDurationOfVisitMax": int, 67 | "tag": list, # user passes in a string which gets converted to a list of ids 68 | "xtag": list, # user passes in a string which gets converted to a list of ids 69 | "authorLocationGroup": list, # user passes in a string which gets converted to a list of ids 70 | "xauthorLocationGroup": list, # user passes in a string which gets converted to a list of ids 71 | "authorLocation": str, 72 | "xauthorLocation": str, 73 | "twitterFollowersMin": int, 74 | "twitterFollowersMax": int, 75 | "twitterFollowingMin": int, 76 | "twitterFollowingMax": int, 77 | "twitterReplyTo": str, 78 | "xtwitterReplyTo": str, 79 | "twitterRetweetOf": str, 80 | "xtwitterRetweetOf": str, 81 | "twitterPostCountMin": int, 82 | "twitterPostCountMax": int, 83 | "twitterRetweetsMin": int, 84 | "twitterRetweetsMax": int, 85 | "reachMin": int, 86 | "reachMax": int, 87 | "influenceMin": int, 88 | "influenceMax": int, 89 | "outreachMin": int, 90 | "outreachMax": int, 91 | "twitterVerified": bool, 92 | "twitterRole": str, 93 | "twitterAuthorId": int, 94 | "xtwitterAuthorId": int, 95 | "impressionsMin": int, 96 | "impressionsMax": int, 97 | "gender": str, 98 | "accountType": (str, list), # one or more filters 99 | "xaccountType": (str, list), # one or more filters 100 | "profession": (str, list), # one or more filters 101 | "xprofession": (str, list), # one or more filters 102 | "interest": (str, list), # one or more filters 103 | "xinterest": (str, list), # one or more filters 104 | "geolocated": bool, 105 | "latitudeMin": int, 106 | "latitudeMax": int, 107 | "longitudeMin": int, 108 | "longitudeMax": int, 109 | "status": str, 110 | "xstatus": str, 111 | "priority": str, 112 | "xpriority": str, 113 | "checked": bool, 114 | "assigned": str, 115 | "xassigned": str, 116 | "threadId": int, 117 | "xthreadId": int, 118 | "threadEntryType": str, 119 | "xthreadEntryType": str, 120 | "threadAuthor": str, 121 | "xthreadAuthor": str, 122 | "postByAuthor": str, 123 | "xpostByAuthor": str, 124 | "shareOfAuthor": str, 125 | "xshareOfAuthor": str, 126 | "replyToAuthor": str, 127 | "xreplyToAuthor": str, 128 | "insightsEmoticon": str, 129 | "xinsightsEmoticon": str, 130 | "insightsHashtag": str, 131 | "xinsightsHashtag": str, 132 | "insightsMentioned": str, 133 | "xinsightsMentioned": str, 134 | "insightsUrl": str, 135 | "xinsightsUrl": str, 136 | "exclusiveLocation": str, 137 | "hourOfDay": str, 138 | "dayOfWeek": str, 139 | "untilAssignmentUpdated": str, 140 | "sinceAssignmentUpdated": str, 141 | } 142 | 143 | """ filters which are limited to a set of options """ 144 | special_options = { 145 | "sentiment": ["positive", "negative", "neutral"], 146 | "gender": ["male", "female"], 147 | "status": ["open", "pending", "closed"], 148 | "xstatus": ["open", "pending", "closed"], 149 | "priority": ["high", "medium", "low"], 150 | "xpriority": ["high", "medium", "low"], 151 | "facebookRole": ["owner", "audience"], 152 | "xfacebookRole": ["owner", "audience"], 153 | "facebookSubtype": ["link", "other", "photo", "status", "video"], 154 | "xfacebookSubtype": ["link", "other", "photo", "status", "video"], 155 | "resourceType": ["public-facebook-post", "public-facebook-comment"], 156 | "xresourceType": ["public-facebook-post", "public-facebook-comment"], 157 | "pageType": [ 158 | "blog", 159 | "forum", 160 | "news", 161 | "general", 162 | "video", 163 | "twitter", 164 | "review", 165 | "image", 166 | "instagram", 167 | "facebook", 168 | ], 169 | "xpageType": [ 170 | "blog", 171 | "forum", 172 | "news", 173 | "general", 174 | "video", 175 | "twitter", 176 | "review", 177 | "image", 178 | "instagram", 179 | "facebook", 180 | ], 181 | "accountType": ["individual", "organizational"], 182 | "xaccountType": ["individual", "organizational"], 183 | "profession": [ 184 | "Executive", 185 | "Student", 186 | "Politician", 187 | "Artist", 188 | "Scientist & Researcher", 189 | "Journalist", 190 | "Software developer & IT", 191 | "Legal", 192 | "Health practitioner", 193 | "Sportpersons & Trainer", 194 | "Sales/Marketing/PR", 195 | "Teacher & Lecturer", 196 | ], 197 | "xprofession": [ 198 | "Executive", 199 | "Student", 200 | "Politician", 201 | "Artist", 202 | "Scientist & Researcher", 203 | "Journalist", 204 | "Software developer & IT", 205 | "Legal", 206 | "Health practitioner", 207 | "Sportpersons & Trainer", 208 | "Sales/Marketing/PR", 209 | "Teacher & Lecturer", 210 | ], 211 | "interest": [ 212 | "Animals & Pets", 213 | "Fine arts", 214 | "Automotive", 215 | "Beauty/Health & Fitness", 216 | "Books", 217 | "Business", 218 | "Environment", 219 | "Family & Parenting", 220 | "Fashion", 221 | "Movies", 222 | "Food & Drinks", 223 | "Games", 224 | "Music", 225 | "Photo & Video", 226 | "Politics", 227 | "Science", 228 | "Shopping", 229 | "Sports", 230 | "Technology", 231 | "Travel", 232 | "TV", 233 | ], 234 | "xinterest": [ 235 | "Animals & Pets", 236 | "Fine arts", 237 | "Automotive", 238 | "Beauty/Health & Fitness", 239 | "Books", 240 | "Business", 241 | "Environment", 242 | "Family & Parenting", 243 | "Fashion", 244 | "Movies", 245 | "Food & Drinks", 246 | "Games", 247 | "Music", 248 | "Photo & Video", 249 | "Politics", 250 | "Science", 251 | "Shopping", 252 | "Sports", 253 | "Technology", 254 | "Travel", 255 | "TV", 256 | ], 257 | } 258 | 259 | """ mention attribultes which can be changed """ 260 | # used with patch_mentions() in queries/groups and with uploads in rules 261 | mutable = { 262 | "addTag": list, 263 | "removeTag": list, 264 | "addCategories": list, 265 | "removeCategories": list, 266 | "priority": str, 267 | "removePriority": str, 268 | "status": str, 269 | "removeStatus": str, 270 | "assignment": str, 271 | "removeAssignment": str, 272 | "sentiment": str, 273 | "checked": bool, 274 | "starred": bool, 275 | "location": str, 276 | } 277 | 278 | mutable_options = { 279 | "sentiment": ["positive", "negative", "neutral"], 280 | "status": ["open", "pending", "closed"], 281 | "removeStatus": ["open", "pending", "closed"], 282 | "priority": ["high", "medium", "low"], 283 | "removePriority": ["high", "medium", "low"], 284 | } 285 | -------------------------------------------------------------------------------- /src/bwapi/bwproject.py: -------------------------------------------------------------------------------- 1 | """ 2 | bwproject contains the BWUser and BWProject classes 3 | """ 4 | 5 | import requests 6 | import time 7 | import logging 8 | 9 | from .credentials import CredentialsStore 10 | 11 | logger = logging.getLogger("bwapi") 12 | handler = logging.StreamHandler() 13 | formatter = logging.Formatter("%(asctime)s %(levelname)s: %(message)s", "%H:%M:%S") 14 | handler.setFormatter(formatter) 15 | logger.addHandler(handler) 16 | logger.setLevel(logging.DEBUG) 17 | 18 | 19 | class BWUser: 20 | """ 21 | This class handles user-level tasks in the Brandwatch API, including authentication and HTTP requests. For tasks which are bound to a project 22 | (e.g. working with queries or groups) use the subclass BWProject instead. 23 | 24 | Attributes: 25 | apiurl: Brandwatch API url. All API requests will be appended to this url. 26 | oauthpath: Path to append to the API url to get an access token. 27 | username: Brandwatch username. 28 | password: Brandwatch password. 29 | token: Access token. 30 | """ 31 | 32 | def __init__( 33 | self, 34 | token=None, 35 | token_path="tokens.txt", 36 | username=None, 37 | password=None, 38 | grant_type="api-password", 39 | client_id="brandwatch-api-client", 40 | apiurl="https://api.brandwatch.com/", 41 | ): 42 | """ 43 | Creates a BWUser object. 44 | 45 | Args: 46 | username: Brandwatch username. 47 | password: Brandwatch password - Optional if you already have an access token. 48 | token: Access token - Optional. 49 | token_path: File path to the file where access tokens will be read from and written to - Optional. Defaults to tokens.txt, pass None to disable. 50 | """ 51 | self.apiurl = apiurl 52 | self.oauthpath = "oauth/token" 53 | self.credentials_store = CredentialsStore(credentials_path=token_path) 54 | if token: 55 | self.username, self.token = self._test_auth(username, token) 56 | self.credentials_store[self.username] = self.token 57 | elif username is not None and password is not None: 58 | self.username, self.token = self._get_auth( 59 | username, password, token_path, grant_type, client_id 60 | ) 61 | if token_path is not None: 62 | self.credentials_store[self.username] = self.token 63 | elif username is not None: 64 | self.username = username 65 | self.token = self.credentials_store[username] 66 | else: 67 | raise KeyError( 68 | "Must provide valid token, username and password, or username and path to token file" 69 | ) 70 | 71 | def _test_auth(self, username, token): 72 | 73 | headers = {} 74 | headers["Authorization"] = "Bearer {}".format(token) 75 | user = requests.get(self.apiurl + "me", headers=headers).json() 76 | 77 | if "username" in user: 78 | if username is None: 79 | return user["username"], token 80 | elif user["username"].lower() == username.lower(): 81 | return username, token 82 | else: 83 | raise KeyError( 84 | "Username " + username + " does not match provided token", user 85 | ) 86 | else: 87 | raise KeyError("Could not validate provided token", user) 88 | 89 | def _get_auth(self, username, password, token_path, grant_type, client_id): 90 | token = requests.post( 91 | self.apiurl + self.oauthpath, 92 | params={ 93 | "username": username, 94 | "grant_type": grant_type, 95 | "client_id": client_id, 96 | }, 97 | data={"password": password}, 98 | ).json() 99 | if "access_token" in token: 100 | return username, token["access_token"] 101 | else: 102 | raise KeyError("Authentication failed", token) 103 | 104 | def get_projects(self): 105 | """ 106 | Gets a list of projects accessible to the user. 107 | 108 | Returns: 109 | List of dictionaries, where each dictionary is the information (name, id, clientName, timezone, ....) for one project. 110 | """ 111 | response = self.request(verb=requests.get, address="projects") 112 | return response["results"] if "results" in response else response 113 | 114 | def get_self(self): 115 | """ Gets username and id """ 116 | return self.request(verb=requests.get, address="me") 117 | 118 | def validate_query_search(self, **kwargs): 119 | """ 120 | Checks a query search to see if it contains errors. Same query debugging as used in the front end. 121 | 122 | Keyword Args: 123 | query: Search terms included in the query. 124 | language: List of the languages in which you'd like to test the query - Optional. 125 | 126 | Raises: 127 | KeyError: If you don't pass a search or if the search has errors in it. 128 | """ 129 | if "query" not in kwargs: 130 | raise KeyError("Must pass: query = 'search terms'") 131 | if "language" not in kwargs: 132 | kwargs["language"] = ["en"] 133 | 134 | valid_search = self.request( 135 | verb=requests.get, address="query-validation", params=kwargs 136 | ) 137 | return valid_search 138 | 139 | def validate_rule_search(self, **kwargs): 140 | """ 141 | Checks a rule search to see if it contains errors. Same rule debugging as used in the front end. 142 | 143 | Keyword Args: 144 | query: Search terms included in the rule. 145 | language: List of the languages in which you'd like to test the query - Optional. 146 | 147 | Raises: 148 | KeyError: If you don't pass a search or if the search has errors in it. 149 | """ 150 | if "query" not in kwargs: 151 | raise KeyError("Must pass: query = 'search terms'") 152 | if "language" not in kwargs: 153 | kwargs["language"] = ["en"] 154 | 155 | valid_search = self.request( 156 | verb=requests.get, address="query-validation/searchwithin", params=kwargs 157 | ) 158 | return valid_search 159 | 160 | def request(self, verb, address, params={}, data={}): 161 | """ 162 | Makes a request to the Brandwatch API. 163 | 164 | Args: 165 | verb: Type of request you want to make (e.g. 'requests.get'). 166 | address: Address to append to the Brandwatch API url. 167 | params: Any additional parameters - Optional. 168 | data: Any additional data - Optional. 169 | 170 | Returns: 171 | The response json 172 | """ 173 | return self.bare_request( 174 | verb=verb, 175 | address_root=self.apiurl, 176 | address_suffix=address, 177 | access_token=self.token, 178 | params=params, 179 | data=data, 180 | ) 181 | 182 | def bare_request( 183 | self, verb, address_root, address_suffix, access_token="", params={}, data={} 184 | ): 185 | """ 186 | Makes a request to the Brandwatch API. 187 | 188 | Args: 189 | verb: Type of request you want to make (e.g. 'requests.get'). 190 | address_root: In most cases this will the the Brandwatch API url, but we leave the flexibility to change this for a different root address if needed. 191 | address_suffix: Address to append to the root url. 192 | access_token: Access token - Optional. 193 | params: Any additional parameters - Optional. 194 | data: Any additional data - Optional. 195 | 196 | Returns: 197 | The response json 198 | """ 199 | time.sleep(0.5) 200 | 201 | headers = {} 202 | 203 | if access_token: 204 | headers["Authorization"] = "Bearer {}".format(access_token) 205 | if data == {}: 206 | response = verb( 207 | address_root + address_suffix, params=params, headers=headers 208 | ) 209 | else: 210 | headers["Content-type"] = "application/json" 211 | response = verb( 212 | address_root + address_suffix, params=params, data=data, headers=headers 213 | ) 214 | 215 | try: 216 | response.json() 217 | except ValueError as e: 218 | # handles non-json responses (e.g. HTTP 404, 500, 502, 503, 504) 219 | if "Expecting value: line 1 column 1 (char 0)" in str(e): 220 | logger.error( 221 | "There was an error with this request: \n{}\n{}\n{}".format( 222 | response.url, data, response.text 223 | ) 224 | ) 225 | raise RuntimeError(response.text) 226 | else: 227 | raise 228 | else: 229 | if "errors" in response.json() and response.json()["errors"]: 230 | logger.error( 231 | "There was an error with this request: \n{}\n{}\n{}".format( 232 | response.url, data, response.json()["errors"] 233 | ) 234 | ) 235 | raise RuntimeError(response.json()["errors"]) 236 | 237 | logger.debug(response.url) 238 | return response.json() 239 | 240 | 241 | class BWProject(BWUser): 242 | """ 243 | This class is required for working with project-level resources, such as queries or groups. 244 | 245 | Attributes: 246 | project_name: Brandwatch project name. 247 | project_id: Brandwatch project id. 248 | project_address: Path to append to the Brandwatch API url to make any project level calls. 249 | """ 250 | 251 | def __init__( 252 | self, 253 | project, 254 | token=None, 255 | token_path="tokens.txt", 256 | username=None, 257 | password=None, 258 | grant_type="api-password", 259 | client_id="brandwatch-api-client", 260 | apiurl="https://api.brandwatch.com/", 261 | ): 262 | """ 263 | Creates a BWProject object - inheriting directly from the BWUser class. 264 | 265 | Args: 266 | username: Brandwatch username. 267 | project: Brandwatch project name. 268 | password: Brandwatch password - Optional if you already have an access token. 269 | token: Access token - Optional. 270 | token_path: File path to the file where access tokens will be read from and written to - Optional. 271 | """ 272 | super().__init__( 273 | token=token, 274 | token_path=token_path, 275 | username=username, 276 | password=password, 277 | grant_type=grant_type, 278 | client_id=client_id, 279 | apiurl=apiurl, 280 | ) 281 | self.project_name = "" 282 | self.project_id = -1 283 | self.project_address = "" 284 | self.get_project(project) 285 | 286 | def get_project(self, project): 287 | """ 288 | Returns a dictionary of the project information (name, id, clientName, timezone, ....). 289 | 290 | Args: 291 | project: Brandwatch project. 292 | """ 293 | projects = self.get_projects() 294 | project_found = False 295 | 296 | try: 297 | int(project) 298 | numerical = True 299 | except ValueError: 300 | numerical = False 301 | 302 | for p in projects: 303 | found = False 304 | if numerical: 305 | if p["id"] == int(project): 306 | found = True 307 | else: 308 | if p["name"] == project: 309 | found = True 310 | if found: 311 | self.project_name = p["name"] 312 | self.project_id = p["id"] 313 | self.project_address = "projects/" + str(self.project_id) + "/" 314 | project_found = True 315 | break 316 | 317 | if not project_found: 318 | raise KeyError("Project " + str(project) + " not found") 319 | 320 | def get(self, endpoint, params={}): 321 | """ 322 | Makes a project level GET request 323 | 324 | Args: 325 | endpoint: Path to append to the Brandwatch project API url. Warning: project information is already included so you don't have to re-append that bit. 326 | params: Additional parameters. 327 | 328 | Returns: 329 | Server's response to the HTTP request. 330 | """ 331 | return self.request( 332 | verb=requests.get, address=self.project_address + endpoint, params=params 333 | ) 334 | 335 | def delete(self, endpoint, params={}): 336 | """ 337 | Makes a project level DELETE request 338 | 339 | Args: 340 | endpoint: Path to append to the Brandwatch project API url. Warning: project information is already included so you don't have to re-append that bit. 341 | params: Additional parameters. 342 | 343 | Returns: 344 | Server's response to the HTTP request. 345 | """ 346 | return self.request( 347 | verb=requests.delete, address=self.project_address + endpoint, params=params 348 | ) 349 | 350 | def post(self, endpoint, params={}, data={}): 351 | """ 352 | Makes a project level POST request 353 | 354 | Args: 355 | endpoint: Path to append to the Brandwatch project API url. Warning: project information is already included so you don't have to re-append that bit. 356 | params: Additional parameters. 357 | data: Additional data. 358 | 359 | Returns: 360 | Server's response to the HTTP request. 361 | """ 362 | return self.request( 363 | verb=requests.post, 364 | address=self.project_address + endpoint, 365 | params=params, 366 | data=data, 367 | ) 368 | 369 | def put(self, endpoint, params={}, data={}): 370 | """ 371 | Makes a project level PUT request 372 | 373 | Args: 374 | endpoint: Path to append to the Brandwatch project API url. Warning: project information is already included so you don't have to re-append that bit. 375 | params: Additional parameters. 376 | data: Additional data. 377 | 378 | Returns: 379 | Server's response to the HTTP request. 380 | """ 381 | return self.request( 382 | verb=requests.put, 383 | address=self.project_address + endpoint, 384 | params=params, 385 | data=data, 386 | ) 387 | 388 | def patch(self, endpoint, params={}, data={}): 389 | """ 390 | Makes a project level PATCH request 391 | 392 | Args: 393 | endpoint: Path to append to the Brandwatch project API url. Warning: project information is already included so you don't have to re-append that bit. 394 | params: Additional parameters. 395 | data: Additional data. 396 | 397 | Returns: 398 | Server's response to the HTTP request. 399 | """ 400 | return self.request( 401 | verb=requests.patch, 402 | address=self.project_address + endpoint, 403 | params=params, 404 | data=data, 405 | ) 406 | -------------------------------------------------------------------------------- /src/bwapi/bwdata.py: -------------------------------------------------------------------------------- 1 | """ 2 | bwdata contains the BWData class. 3 | """ 4 | import datetime 5 | from . import filters 6 | import logging 7 | 8 | logger = logging.getLogger("bwapi") 9 | 10 | 11 | class BWData: 12 | """ 13 | This class is a superclass for brandwatch BWQueries and BWGroups. It was built to handle resources that access data (e.g. mentions, topics, charts, etc). 14 | """ 15 | 16 | def get_mentions(self, name=None, startDate=None, max_pages=None, **kwargs): 17 | """ 18 | Retrieves a list of mentions. 19 | Note: Clients do not have access to full Twitter mentions through the API because of our data agreement with Twitter. 20 | 21 | Args: 22 | name: You must pass in a query / group name (string). 23 | startDate: You must pass in a start date (string). 24 | max_pages: Maximum number of pages to retrieve, where each page is 5000 mentions by default - Optional. If you don't pass max_pages, it will retrieve all mentions that match your request. 25 | kwargs: All other filters are optional and can be found in filters.py. 26 | 27 | Raises: 28 | KeyError: If the mentions call fails. 29 | 30 | Returns: 31 | A list of mentions. 32 | """ 33 | kwargs = { 34 | key: value for (key, value) in kwargs.items() if key != "iter_by_page" 35 | } 36 | all_mentions = list( 37 | self.iter_mentions( 38 | name=name, 39 | startDate=startDate, 40 | max_pages=max_pages, 41 | iter_by_page=False, 42 | **kwargs 43 | ) 44 | ) 45 | logger.info("{} mentions downloaded".format(len(all_mentions))) 46 | return all_mentions 47 | 48 | def iter_mentions( 49 | self, name=None, startDate=None, max_pages=None, iter_by_page=False, **kwargs 50 | ): 51 | """ 52 | Same as get_mentions function, but returns an iterator. Fetch one page at a time to reduce memory footprint. 53 | 54 | Args: 55 | name: You must pass in a query / group name (string). 56 | startDate: You must pass in a start date (string). 57 | max_pages: Maximum number of pages to retrieve, where each page is 5000 mentions by default - Optional. If you don't pass max_pages, it will retrieve all mentions that match your request. 58 | iter_by_page: Enumerate by page when set to True, else by mention, default to False - Optional. 59 | kwargs: All other filters are optional and can be found in filters.py. 60 | 61 | Raises: 62 | KeyError: If the mentions call fails. 63 | 64 | Returns: 65 | A list of mentions. 66 | """ 67 | params = self._fill_params(name, startDate, kwargs) 68 | page_size = kwargs["pageSize"] if "pageSize" in kwargs else 5000 69 | params["pageSize"] = page_size 70 | cursor = params.get("cursor", None) 71 | page_idx = 0 72 | 73 | while True: 74 | if cursor: 75 | params["cursor"] = cursor 76 | if max_pages and page_idx >= max_pages: 77 | break 78 | else: 79 | page_idx += 1 80 | next_cursor, next_mentions = self._get_mentions_page(params) 81 | if len(next_mentions) > 0: 82 | cursor = next_cursor 83 | logger.info( 84 | "Mentions page {} of {} {} retrieved".format( 85 | page_idx, self.resource_type, name 86 | ) 87 | ) 88 | if iter_by_page: 89 | yield next_mentions 90 | else: 91 | for mention in next_mentions: 92 | yield mention 93 | if len(next_mentions) < page_size or not next_cursor: 94 | break 95 | 96 | def num_mentions(self, name=None, startDate=None, **kwargs): 97 | """ 98 | Retrieves a count of the mentions in a given timeframe. 99 | 100 | Args: 101 | name: You must pass in a query / group name (string). 102 | startDate: You must pass in a start date (string). 103 | kwargs: All other filters are optional and can be found in filters.py. 104 | 105 | Returns: 106 | A count of the mentions in a given timeframe. 107 | """ 108 | params = self._fill_params(name, startDate, kwargs) 109 | return self.project.get(endpoint="data/mentions/count", params=params)[ 110 | "mentionsCount" 111 | ] 112 | 113 | def get_chart( 114 | self, 115 | name=None, 116 | startDate=None, 117 | y_axis=None, 118 | x_axis=None, 119 | breakdown_by=None, 120 | **kwargs 121 | ): 122 | """ 123 | Retrieves chart data. 124 | 125 | Args: 126 | name: You must pass in a query / group name (string). 127 | startDate: You must pass in a start date (string). 128 | y_axis: Pass in the y axis of your chart (string in camel case). See Brandwatch app dropdown menu "For (Y-Axis)" for options 129 | x_axis: Pass in the x axis of your chart (string in camel case). See Brandwatch app dropdown menu "Show (X-Axis)" for options. 130 | breakdown_by: Pass in breakdown_by (string in camel case). See Brandwatch app dropdown menu "Breakdown by" for options. 131 | kwargs: You must pass in name (query name/group) and startDate (string). Additionally, if you x_axis or breakdown_by consists of categories or tags you must pass in dim1Args or dim2Args, respectively, which should be a list of the names of those cats/tags. All other filters are optional and can be found in filters.py. 132 | 133 | Returns: 134 | A dictionary representation of the specified chart 135 | 136 | Raises: 137 | KeyError: If you fail to pass in x_axis, y_axis or breakdown_by. 138 | 139 | """ 140 | if not (y_axis and x_axis and breakdown_by): 141 | raise KeyError("You must pass in an y_axis, x_axis and breakdown_by") 142 | 143 | params = self._fill_params(name, startDate, kwargs) 144 | if "dim1Args" in params: 145 | params["dim1Args"] = self._name_to_id(x_axis, params["dim1Args"]) 146 | if "dim2Args" in params: 147 | params["dim2Args"] = self._name_to_id(breakdown_by, params["dim2Args"]) 148 | 149 | return self.project.get( 150 | endpoint="data/" + y_axis + "/" + x_axis + "/" + breakdown_by, params=params 151 | ) 152 | 153 | def get_topics(self, name=None, startDate=None, **kwargs): 154 | """ 155 | Retrieves topics data. 156 | 157 | Args: 158 | name: You must pass in a query / group name (string). 159 | startDate: You must pass in a start date (string). 160 | kwargs: All other filters are optional and can be found in filters.py. 161 | 162 | Returns: 163 | A dictionary representation of the topics including everything that can be seen in the chart view of the topics cloud (e.g. the topic, the number of mentions including that topic, the number of mentions by sentiment, the burst value, etc) 164 | """ 165 | params = self._fill_params(name, startDate, kwargs) 166 | return self.project.get(endpoint="data/volume/topics/queries", params=params)[ 167 | "topics" 168 | ] 169 | 170 | def get_topics_comparison(self, name=None, startDate=None, **kwargs): 171 | """ 172 | Retrieves topics comparison data. 173 | 174 | Args: 175 | name: You must pass in a query / group name (string). 176 | startDate: You must pass in a start date (string). 177 | kwargs: All other filters are optional and can be found in filters.py. 178 | 179 | Returns: 180 | A dictionary representation of the topics including everything that can be seen in the chart view of the topics comparison (e.g. the topic, the number of mentions including that topic, the number of mentions by sentiment, the burst value, etc) 181 | """ 182 | params = self._fill_params(name, startDate, kwargs) 183 | return self.project.get( 184 | endpoint="data/volume/topics/compare/gender", params=params 185 | )["topics"] 186 | 187 | def get_authors(self, name=None, startDate=None, **kwargs): 188 | """ 189 | Retrieves author data. 190 | 191 | Args: 192 | name: You must pass in a query / group name (string). 193 | startDate: You must pass in a start date (string). 194 | kwargs: All other filters are optional and can be found in filters.py. 195 | 196 | Returns: 197 | A dictionary representation of the authors including everything that can be seen in the list of authors 198 | """ 199 | params = self._fill_params(name, startDate, kwargs) 200 | return self.project.get( 201 | endpoint="data/volume/topauthors/queries", params=params 202 | )["results"] 203 | 204 | def get_history(self, name=None, startDate=None, **kwargs): 205 | """ 206 | Retrieves history data. 207 | 208 | Args: 209 | name: You must pass in a query / group name (string). 210 | startDate: You must pass in a start date (string). 211 | kwargs: All other filters are optional and can be found in filters.py. 212 | 213 | Returns: 214 | A dictionary representation of the history component, all the points of time that the timeline covers 215 | """ 216 | params = self._fill_params(name, startDate, kwargs) 217 | return self.project.get(endpoint="data/volume/queries/days", params=params)[ 218 | "results" 219 | ] 220 | 221 | def get_topsites(self, name=None, startDate=None, **kwargs): 222 | """ 223 | Retrieves top sites data. 224 | 225 | Args: 226 | name: You must pass in a query / group name (string). 227 | startDate: You must pass in a start date (string). 228 | kwargs: All other filters are optional and can be found in filters.py. 229 | 230 | Returns: 231 | A dictionary representation of top sites 232 | """ 233 | params = self._fill_params(name, startDate, kwargs) 234 | return self.project.get(endpoint="data/volume/topsites/queries", params=params)[ 235 | "results" 236 | ] 237 | 238 | def get_tweeters(self, name=None, startDate=None, **kwargs): 239 | """ 240 | Retrieves tweeters data. 241 | 242 | Args: 243 | name: You must pass in a query / group name (string). 244 | startDate: You must pass in a start date (string). 245 | kwargs: All other filters are optional and can be found in filters.py. 246 | 247 | Returns: 248 | A dictionary representation of top tweeters 249 | """ 250 | params = self._fill_params(name, startDate, kwargs) 251 | return self.project.get( 252 | endpoint="data/volume/toptweeters/queries", params=params 253 | )["results"] 254 | 255 | def get_volume(self, name=None, startDate=None, **kwargs): 256 | """ 257 | Retrieves volume data. 258 | 259 | Args: 260 | name: You must pass in a query / group name (string). 261 | startDate: You must pass in a start date (string). 262 | kwargs: All other filters are optional and can be found in filters.py. 263 | 264 | Returns: 265 | A dictionary representation of volume data 266 | """ 267 | params = self._fill_params(name, startDate, kwargs) 268 | return self.project.get( 269 | endpoint="data/volume/queries/pageTypes", params=params 270 | )["results"] 271 | 272 | def get_world(self, name=None, startDate=None, **kwargs): 273 | """ 274 | Retrieves world overview (mentions map) data. 275 | 276 | Args: 277 | name: You must pass in a query / group name (string). 278 | startDate: You must pass in a start date (string). 279 | kwargs: All other filters are optional and can be found in filters.py. 280 | 281 | Returns: 282 | A dictionary representation of mapped mentions on a globe data 283 | """ 284 | params = self._fill_params(name, startDate, kwargs) 285 | return self.project.get( 286 | endpoint="data/volume/queries/countries", params=params 287 | )["results"]["values"] 288 | 289 | def get_keyinsights(self, name=None, startDate=None, **kwargs): 290 | """ 291 | Retrieves key insights component data. 292 | 293 | Args: 294 | name: You must pass in a query / group name (string). 295 | startDate: You must pass in a start date (string). 296 | kwargs: All other filters are optional and can be found in filters.py. 297 | 298 | Returns: 299 | A dictionary representation of the component key insights 300 | """ 301 | key_insights = { 302 | "total_mentions": self.get_keyinsights_mention_count(name, startDate), 303 | "unique_authors": self.get_keyinsights_author_count(name, startDate), 304 | "topic_trends": self.get_keyinsights_topics(name, startDate), 305 | "rising_news": self.get_keyinsights_news(name, startDate), 306 | } 307 | return key_insights 308 | 309 | def get_keyinsights_mention_count(self, name=None, startDate=None, **kwargs): 310 | """ 311 | Retrieves total mentions count data from key insights component. 312 | 313 | Args: 314 | name: You must pass in a query / group name (string). 315 | startDate: You must pass in a start date (string). 316 | kwargs: All other filters are optional and can be found in filters.py. 317 | 318 | Returns: 319 | An integer that represents the total number of mentions 320 | """ 321 | params = self._fill_params(name, startDate, kwargs) 322 | return self.project.get(endpoint="data/mentions/count", params=params)[ 323 | "mentionsCount" 324 | ] 325 | 326 | def get_keyinsights_author_count(self, name=None, startDate=None, **kwargs): 327 | """ 328 | Retrieves total unique authors count data from key insights component. 329 | 330 | Args: 331 | name: You must pass in a query / group name (string). 332 | startDate: You must pass in a start date (string). 333 | kwargs: All other filters are optional and can be found in filters.py. 334 | 335 | Returns: 336 | An integer that represents the total number of unique authors 337 | """ 338 | params = self._fill_params(name, startDate, kwargs) 339 | return self.project.get(endpoint="data/authors/months/queries", params=params)[ 340 | "results" 341 | ][0]["values"][0]["value"] 342 | 343 | def get_keyinsights_topics(self, name=None, startDate=None, **kwargs): 344 | """ 345 | Retrieves the top 3 trending topics data from key insights component. 346 | 347 | Args: 348 | name: You must pass in a query / group name (string). 349 | startDate: You must pass in a start date (string). 350 | kwargs: All other filters are optional and can be found in filters.py. 351 | 352 | Returns: 353 | A dictionary representation of the top 3 trending topics 354 | """ 355 | params = self._fill_params(name, startDate, kwargs) 356 | params["limit"] = kwargs["limit"] if "limit" in kwargs else 3 357 | return self.project.get(endpoint="data/volume/topics/queries", params=params)[ 358 | "topics" 359 | ] 360 | 361 | def get_keyinsights_news(self, name=None, startDate=None, **kwargs): 362 | """ 363 | Retrieves the top 3 rising news data from the key insights component. 364 | 365 | Args: 366 | name: You must pass in a query / group name (string). 367 | startDate: You must pass in a start date (string). 368 | kwargs: All other filters are optional and can be found in filters.py. 369 | 370 | Returns: 371 | A dictionary representation of the rising top 3 rising news urls 372 | """ 373 | params = self._fill_params(name, startDate, kwargs) 374 | params["pageSize"] = kwargs["pageSize"] if "pageSize" in kwargs else 3 375 | return self.project.get(endpoint="data/mentions", params=params)["results"] 376 | 377 | def get_summary(self, name=None, startDate=None, **kwargs): 378 | """ 379 | Retrieves the summary component data. 380 | 381 | Args: 382 | name: You must pass in a query / group name (string). 383 | startDate: You must pass in a start date (string). 384 | kwargs: All other filters are optional and can be found in filters.py. 385 | 386 | Returns: 387 | A dictionary representation of the summary component analysis 388 | """ 389 | summary = { 390 | "sentiment": self.get_summary_sentiment(name, startDate), 391 | "topsites": self.get_summary_topsites(name, startDate), 392 | "pagetypes": self.get_summary_pagetypes(name, startDate), 393 | } 394 | return summary 395 | 396 | def get_summary_sentiment(self, name=None, startDate=None, **kwargs): 397 | """ 398 | Retrieves the sentiment data from the summary component. 399 | 400 | Args: 401 | name: You must pass in a query / group name (string). 402 | startDate: You must pass in a start date (string). 403 | kwargs: All other filters are optional and can be found in filters.py. 404 | 405 | Returns: 406 | A dictionary representation of the summary sentiment analysis 407 | """ 408 | params = self._fill_params(name, startDate, kwargs) 409 | return self.project.get(endpoint="data/volume/sentiment/days", params=params)[ 410 | "results" 411 | ] 412 | 413 | def get_summary_topsites(self, name=None, startDate=None, **kwargs): 414 | """ 415 | Retrieves the top sites data from the summary component. 416 | 417 | Args: 418 | name: You must pass in a query / group name (string). 419 | startDate: You must pass in a start date (string). 420 | kwargs: All other filters are optional and can be found in filters.py. 421 | 422 | Returns: 423 | A dictionary representation of the summary sites analysis 424 | """ 425 | params = self._fill_params(name, startDate, kwargs) 426 | return self.project.get(endpoint="data/volume/topsites/queries", params=params)[ 427 | "results" 428 | ] 429 | 430 | def get_summary_pagetypes(self, name=None, startDate=None, **kwargs): 431 | """ 432 | Retrieves the top page type data from the summary component. 433 | 434 | Args: 435 | name: You must pass in a query / group name (string). 436 | startDate: You must pass in a start date (string). 437 | kwargs: All other filters are optional and can be found in filters.py. 438 | 439 | Returns: 440 | A dictionary representation of the summary page type analysis 441 | """ 442 | params = self._fill_params(name, startDate, kwargs) 443 | return self.project.get( 444 | endpoint="data/volume/queries/pageTypes", params=params 445 | )["results"] 446 | 447 | def get_twitter_insights(self, name=None, startDate=None, **kwargs): 448 | """ 449 | Retrieves the twitter insights component data. 450 | 451 | Args: 452 | name: You must pass in a query / group name (string). 453 | startDate: You must pass in a start date (string). 454 | kwargs: All other filters are optional and can be found in filters.py. 455 | 456 | Returns: 457 | A dictionary representation of the twitter insights component data 458 | """ 459 | twitter_insights = { 460 | "hashtags": self.get_twitter_insights_feature(name, startDate, "hashtags"), 461 | "emoticons": self.get_twitter_insights_feature( 462 | name, startDate, "emoticons" 463 | ), 464 | "urls": self.get_twitter_insights_feature(name, startDate, "urls"), 465 | "mentionedauthors": self.get_twitter_insights_feature( 466 | name, startDate, "mentionedauthors" 467 | ), 468 | } 469 | return twitter_insights 470 | 471 | def get_twitter_insights_feature( 472 | self, name=None, startDate=None, feature=None, **kwargs 473 | ): 474 | """ 475 | Retrieves the a feature from the twitter insights component. 476 | 477 | Args: 478 | name: You must pass in a query / group name (string). 479 | startDate: You must pass in a start date (string). 480 | feature: Pass in a feature of the twitter insights component (written in lowercase within a string). This is either hashtags, emoticons, urls, or mentionedauthors. 481 | kwargs: All other filters are optional and can be found in filters.py. 482 | 483 | Returns: 484 | A dictionary representation of the feature of the twitter insights analysis component 485 | """ 486 | if not feature: 487 | raise KeyError("You must pass in a feature") 488 | 489 | params = self._fill_params(name, startDate, kwargs) 490 | return self.project.get(endpoint="data/" + feature, params=params) 491 | 492 | def get_volume_group(self, name=None, startDate=None, **kwargs): 493 | """ 494 | Retrieves the volume for group data. 495 | 496 | Args: 497 | name: You must pass in a query / group name (string). 498 | startDate: You must pass in a start date (string). 499 | kwargs: All other filters are optional and can be found in filters.py. 500 | 501 | Returns: 502 | A dictionary representation of the volume for group data 503 | """ 504 | params = self._fill_params(name, startDate, kwargs) 505 | return self.project.get( 506 | endpoint="data/volume/queries/sentiment", params=params 507 | )["results"] 508 | 509 | def get_date_range_comparison( 510 | self, name=None, startDate=None, date_ranges=None, **kwargs 511 | ): 512 | """ 513 | Retrieves the date range data 514 | 515 | Args: 516 | name: You must pass in a query / group name (string). 517 | startDate: You must pass in a start date (string). 518 | date_ranges: You must pass in date range(s) ([list] of strings). 519 | kwargs: All other filters are optional and can be found in filters.py. 520 | 521 | Returns: 522 | A dictionary representation of the date range data applied on the query 523 | 524 | Raises: 525 | KeyError: If you fail to pass in a date range 526 | """ 527 | query_id = self.get_resource_id(name) 528 | date_range_list = self._get_date_ranges(query_id) 529 | date_range_ids = [ 530 | dr["id"] for dr in date_range_list if dr["name"] in date_ranges 531 | ] 532 | 533 | if date_range_ids == [] or date_ranges is None: 534 | raise KeyError("You must pass in a valid list of date range(s)") 535 | 536 | params = self._fill_params(name, startDate, kwargs) 537 | params["dateRanges"] = date_range_ids 538 | return self.project.get(endpoint="data/volume/dateRanges/days", params=params)[ 539 | "results" 540 | ] 541 | 542 | # Channels 543 | 544 | def get_fb_analytics(self, name=None, startDate=None, **kwargs): 545 | """ 546 | Retrieves the entire facebook analytics component data. 547 | 548 | Args: 549 | name: You must pass in a channel / group name (string). 550 | startDate: You must pass in a start date (string). 551 | 552 | kwargs: All other filters are optional and can be found in filters.py. 553 | 554 | Returns: 555 | A dictionary representation of the entire facebook analytics component data 556 | """ 557 | fb_analytics = { 558 | "audience": self.get_fb_analytics_partial(name, startDate, "audience"), 559 | "ownerActivity": self.get_fb_analytics_partial( 560 | name, startDate, "ownerActivity" 561 | ), 562 | "audienceActivity": self.get_fb_analytics_partial( 563 | name, startDate, "audienceActivity" 564 | ), 565 | "impressions": self.get_fb_analytics_partial( 566 | name, startDate, "impressions" 567 | ), 568 | } 569 | return fb_analytics 570 | 571 | def get_fb_analytics_partial( 572 | self, name=None, startDate=None, metadata_type=None, **kwargs 573 | ): 574 | """ 575 | Retrieves the specified part of the facebook analytics component data. 576 | 577 | Args: 578 | name: You must pass in a channel / group name (string). 579 | startDate: You must pass in a start date (string). 580 | metadata_type: You must pass in the type of facebook analytics data you want (string). This can be either audience, ownerActivity, audienceActivity, or impressions. 581 | 582 | kwargs: All other filters are optional and can be found in filters.py. 583 | 584 | Returns: 585 | A dictionary representation of the specified part of the facebook analytics component data 586 | """ 587 | if not metadata_type: 588 | raise KeyError("You must pass in a metadata_type") 589 | 590 | params = self._fill_params(name, startDate, kwargs) 591 | return self.project.get( 592 | endpoint="data/" + metadata_type + "/queries/days", params=params 593 | )["results"][0]["values"] 594 | 595 | def get_fb_audience(self, name=None, startDate=None, **kwargs): 596 | """ 597 | Retrieves the facebook audience component data. 598 | 599 | Args: 600 | name: You must pass in a channel / group name (string). 601 | startDate: You must pass in a start date (string). 602 | 603 | kwargs: All other filters are optional and can be found in filters.py. 604 | 605 | Returns: 606 | A list of facebook authors, each having a dictionary representation of their respective facebook data 607 | """ 608 | params = self._fill_params(name, startDate, kwargs) 609 | return self.project.get( 610 | endpoint="data/volume/topfacebookusers/queries", params=params 611 | )["results"] 612 | 613 | def get_fb_comments(self, name=None, startDate=None, **kwargs): 614 | """ 615 | Retrieves the facebook comments component data. 616 | 617 | Args: 618 | name: You must pass in a channel / group name (string). 619 | startDate: You must pass in a start date (string). 620 | 621 | kwargs: All other filters are optional and can be found in filters.py. 622 | 623 | Returns: 624 | A list of facebook authors, each having a dictionary representation of their respective facebook data 625 | """ 626 | params = self._fill_params(name, startDate, kwargs) 627 | return self.project.get( 628 | endpoint="data/mentions/facebookcomments", params=params 629 | )["results"] 630 | 631 | def get_fb_posts(self, name=None, startDate=None, **kwargs): 632 | """ 633 | Retrieves the facebook posts component data. 634 | 635 | Args: 636 | name: You must pass in a channel / group name (string). 637 | startDate: You must pass in a start date (string). 638 | 639 | kwargs: All other filters are optional and can be found in filters.py. 640 | 641 | Returns: 642 | A list of facebook authors, each having a dictionary representation of their respective facebook data 643 | """ 644 | params = self._fill_params(name, startDate, kwargs) 645 | return self.project.get(endpoint="data/mentions/facebookposts", params=params)[ 646 | "results" 647 | ] 648 | 649 | def get_ig_interactions(self, name=None, startDate=None, **kwargs): 650 | """ 651 | Retrieves the entire instagram interactions component data. 652 | 653 | Args: 654 | name: You must pass in a channel / group name (string). 655 | startDate: You must pass in a start date (string). 656 | 657 | kwargs: All other filters are optional and can be found in filters.py. 658 | 659 | Returns: 660 | A dictionary representation of the entire instagram interactions component data. 661 | """ 662 | instagram_interactions = { 663 | "ownerActivity": self.get_ig_interactions_partial( 664 | name, startDate, "ownerActivity" 665 | ), 666 | "audienceActivity": self.get_ig_interactions_partial( 667 | name, startDate, "audienceActivity" 668 | ), 669 | } 670 | return instagram_interactions 671 | 672 | def get_ig_interactions_partial( 673 | self, name=None, startDate=None, metadata_type=None, **kwargs 674 | ): 675 | """ 676 | Retrieves the specified part of the instagram interactions component data. 677 | 678 | Args: 679 | name: You must pass in a channel / group name (string). 680 | startDate: You must pass in a start date (string). 681 | metadata_type: You must pass in the type of instagram interactions data you want (string). This can be either ownerActivity or audienceActivity. 682 | 683 | kwargs: All other filters are optional and can be found in filters.py. 684 | 685 | Returns: 686 | A dictionary representation of the specified part of the instagram interactions component data. 687 | """ 688 | if not metadata_type: 689 | raise KeyError("You must pass in a metadata_type") 690 | 691 | params = self._fill_params(name, startDate, kwargs) 692 | return self.project.get( 693 | endpoint="data/" + metadata_type + "/queries/days", params=params 694 | )["results"][0] 695 | 696 | def get_ig_insights(self, name=None, startDate=None, **kwargs): 697 | """ 698 | Retrieves the entire instagram owner insights component data. 699 | 700 | Args: 701 | name: You must pass in a channel / group name (string). 702 | startDate: You must pass in a start date (string). 703 | 704 | kwargs: All other filters are optional and can be found in filters.py. 705 | 706 | Returns: 707 | A dictionary representation of the entire instagram owner insights component data. 708 | """ 709 | instagram_insights = { 710 | "mentionedauthors": self.get_ig_insights_partial( 711 | name, startDate, "mentionedauthors" 712 | ), 713 | "hashtags": self.get_ig_insights_partial(name, startDate, "hashtags"), 714 | "emoticons": self.get_ig_insights_partial(name, startDate, "emoticons"), 715 | } 716 | return instagram_insights 717 | 718 | def get_ig_insights_partial( 719 | self, name=None, startDate=None, metadata_type=None, **kwargs 720 | ): 721 | """ 722 | Retrieves the specified part of the instagram owner insights component data. 723 | 724 | Args: 725 | name: You must pass in a channel / group name (string). 726 | startDate: You must pass in a start date (string). 727 | metadata_type: You must pass in the type of instagram insights data you want (string). This can be either hashtags, mentionedauthors, or emoticons. 728 | 729 | kwargs: All other filters are optional and can be found in filters.py. 730 | 731 | Returns: 732 | A list of authors, hashtags, or emoticons, each having a dictionary representation of their respective instagram insights data 733 | """ 734 | if not metadata_type: 735 | raise KeyError("You must pass in a metadata_type") 736 | 737 | params = self._fill_params(name, startDate, kwargs) 738 | return self.project.get(endpoint="data/" + metadata_type, params=params)[ 739 | "results" 740 | ] 741 | 742 | def get_ig_posts(self, name=None, startDate=None, **kwargs): 743 | """ 744 | Retrieves the instagram posts component data. 745 | 746 | Args: 747 | name: You must pass in a channel / group name (string). 748 | startDate: You must pass in a start date (string). 749 | 750 | kwargs: All other filters are optional and can be found in filters.py. 751 | 752 | Returns: 753 | A list of instagram authors, each having a dictionary representation of their respective instagram data 754 | """ 755 | 756 | params = self._fill_params(name, startDate, kwargs) 757 | return self.project.get(endpoint="data/mentions", params=params)["results"] 758 | 759 | def get_ig_followers(self, name=None, startDate=None, **kwargs): 760 | """ 761 | Retrieves the instagram total followers component data. 762 | 763 | Args: 764 | name: You must pass in a channel / group name (string). 765 | startDate: You must pass in a start date (string). 766 | 767 | kwargs: All other filters are optional and can be found in filters.py. 768 | 769 | Returns: 770 | A list with the follower count for each day in the date range, each day having a dictionary representation of their respective instagram follower count data 771 | """ 772 | 773 | params = self._fill_params(name, startDate, kwargs) 774 | return self.project.get(endpoint="data/audience/queries/days", params=params)[ 775 | "results" 776 | ][0]["values"] 777 | 778 | def get_tweets(self, name=None, startDate=None, **kwargs): 779 | """ 780 | Retrieves the twitter tweets component data. 781 | 782 | Args: 783 | name: You must pass in a channel / group name (string). 784 | startDate: You must pass in a start date (string). 785 | 786 | kwargs: All other filters are optional and can be found in filters.py. 787 | 788 | Returns: 789 | A list of tweets with author, location, and other metadata, each tweet having a dictionary representation of their respective tweet data 790 | """ 791 | 792 | params = self._fill_params(name, startDate, kwargs) 793 | return self.project.get(endpoint="data/mentions/tweets", params=params)[ 794 | "results" 795 | ] 796 | 797 | def get_tw_analytics(self, name=None, startDate=None, **kwargs): 798 | """ 799 | Retrieves the entire twitter analytics component data. 800 | 801 | Args: 802 | name: You must pass in a channel / group name (string). 803 | startDate: You must pass in a start date (string). 804 | 805 | kwargs: All other filters are optional and can be found in filters.py. 806 | 807 | Returns: 808 | A dictionary representation of the entire twitter analytics component data 809 | """ 810 | tw_analytics = { 811 | "audience": self.get_tw_analytics_partial(name, startDate, "audience"), 812 | "ownerActivity": self.get_tw_analytics_partial( 813 | name, startDate, "ownerActivity" 814 | ), 815 | "audienceActivity": self.get_tw_analytics_partial( 816 | name, startDate, "audienceActivity" 817 | ), 818 | "impressions": self.get_tw_analytics_partial( 819 | name, startDate, "impressions" 820 | ), 821 | } 822 | return tw_analytics 823 | 824 | def get_tw_analytics_partial( 825 | self, name=None, startDate=None, metadata_type=None, **kwargs 826 | ): 827 | """ 828 | Retrieves the specified part of the twitter analytics component data. 829 | 830 | Args: 831 | name: You must pass in a channel / group name (string). 832 | startDate: You must pass in a start date (string). 833 | metadata_type: You must pass in the type of twitter analytics data you want (string). This can be either audience, ownerActivity, audienceActivity, or impressions. 834 | 835 | kwargs: All other filters are optional and can be found in filters.py. 836 | 837 | Returns: 838 | A dictionary representation of the specified part of the twitter analytics component data 839 | """ 840 | if not metadata_type: 841 | raise KeyError("You must pass in a metadata_type") 842 | 843 | params = self._fill_params(name, startDate, kwargs) 844 | return self.project.get( 845 | endpoint="data/" + metadata_type + "/queries/days", params=params 846 | )["results"][0]["values"] 847 | 848 | def get_tw_audience(self, name=None, startDate=None, **kwargs): 849 | """ 850 | Retrieves the twitter audience component data. 851 | 852 | Args: 853 | name: You must pass in a channel / group name (string). 854 | startDate: You must pass in a start date (string). 855 | 856 | kwargs: All other filters are optional and can be found in filters.py. 857 | 858 | Returns: 859 | A list of twitter authors, each having a dictionary representation of their respective twitter data 860 | """ 861 | params = self._fill_params(name, startDate, kwargs) 862 | return self.project.get( 863 | endpoint="data/volume/toptweeters/queries", params=params 864 | )["results"] 865 | 866 | def get_dem_summary(self, name=None, startDate=None, **kwargs): 867 | """ 868 | Retrieves the entire demographics summary component data. 869 | 870 | Args: 871 | name: You must pass in a channel / group name (string). 872 | startDate: You must pass in a start date (string). 873 | 874 | kwargs: All other filters are optional and can be found in filters.py. 875 | 876 | Returns: 877 | A dictionary representation of the entire demographics summary component data 878 | """ 879 | dem_summary = { 880 | "gender": self.get_dem_summary_partial(name, startDate, "gender"), 881 | "interest": self.get_dem_summary_partial(name, startDate, "interest"), 882 | "profession": self.get_dem_summary_partial(name, startDate, "profession"), 883 | "countries": self.get_dem_summary_partial(name, startDate, "countries"), 884 | } 885 | return dem_summary 886 | 887 | def get_dem_summary_partial( 888 | self, name=None, startDate=None, metadata_type=None, **kwargs 889 | ): 890 | """ 891 | Retrieves a specified part of the demographic summary component data extracted from twitter data. 892 | 893 | Args: 894 | name: You must pass in a channel / group name (string). 895 | startDate: You must pass in a start date (string). 896 | metadata_type: You must pass in the type of demographic summary data you want (string). This can be either gender, interest, profession, or countries. 897 | 898 | 899 | kwargs: All other filters are optional and can be found in filters.py. 900 | 901 | Returns: 902 | A dictionary representation of the top breakdowns (gender, interest, etc) 903 | """ 904 | 905 | if not metadata_type: 906 | raise KeyError("You must pass in a metadata_type") 907 | 908 | params = self._fill_params(name, startDate, kwargs) 909 | return self.project.get( 910 | endpoint="data/demographics/" + metadata_type, params=params 911 | ) 912 | 913 | def _get_date_ranges(self, query_id=None): 914 | """ 915 | Helper method: Gets the date range for a query 916 | 917 | Args: 918 | query_id: You must pass in a query / group id (integer). 919 | date_ranges: You must pass in date range(s) ([list] of strings). 920 | 921 | Returns: 922 | A dictionary representation of the date ranges available for the specified query 923 | 924 | """ 925 | return self.project.get( 926 | endpoint="queries/" + str(query_id) + "/" + "date-range" 927 | ) 928 | 929 | def _fill_params(self, name, startDate, data): 930 | try: 931 | name = int(name) 932 | except ValueError: 933 | pass 934 | 935 | name_list = [name] if isinstance(name, (str, int)) else name 936 | id_list = [] 937 | 938 | for name in name_list: 939 | if isinstance(name, str): 940 | if not self.check_resource_exists(name): 941 | logger.error( 942 | "Could not find {} with name {}".format( 943 | self.resource_type, name 944 | ) 945 | ) 946 | else: 947 | id_list.append(self.get_resource_id(name)) 948 | elif isinstance(name, int): 949 | if not self.check_resource_exists(name): 950 | logger.error( 951 | "Could not find {} with id {}".format(self.resource_type, name) 952 | ) 953 | else: 954 | id_list.append(name) 955 | else: 956 | logger.error( 957 | "Must reference {} with type string or int".format( 958 | self.resource_type 959 | ), 960 | name, 961 | ) 962 | 963 | if len(id_list) == 0: 964 | raise RuntimeError( 965 | "No valid {} ids could be extracted".format(self.resource_type), name 966 | ) 967 | 968 | filled = {} 969 | filled[self.resource_id_name] = id_list 970 | filled["startDate"] = startDate 971 | filled["endDate"] = ( 972 | data["endDate"] 973 | if "endDate" in data 974 | else (datetime.date.today() + datetime.timedelta(days=1)).isoformat() 975 | ) 976 | 977 | if "orderBy" in data: 978 | filled["orderBy"] = data["orderBy"] 979 | if "orderDirection" in data: 980 | filled["orderDirection"] = data["orderDirection"] 981 | 982 | for param in data: 983 | setting = self._name_to_id(param, data[param]) 984 | if self._valid_input(param, setting): 985 | filled[param] = setting 986 | else: 987 | raise KeyError("invalid input for given parameter", param) 988 | 989 | return filled 990 | 991 | def _get_mentions_page(self, params): 992 | mentions = self.project.get(endpoint="data/mentions/fulltext", params=params) 993 | if "errors" in mentions: 994 | raise KeyError("Mentions GET request failed", mentions) 995 | 996 | return mentions.get("nextCursor", None), mentions["results"] 997 | 998 | def _valid_input(self, param, setting): 999 | if (param in filters.params) and ( 1000 | not isinstance(setting, filters.params[param]) 1001 | ): 1002 | return False 1003 | elif param in filters.special_options: 1004 | setting = setting if isinstance(setting, list) else [setting] 1005 | return all(map(lambda x: x in filters.special_options[param], setting)) 1006 | else: 1007 | return True 1008 | -------------------------------------------------------------------------------- /DEMO.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# SDK for the Brandwatch API: Demo" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "## Introduction" 15 | ] 16 | }, 17 | { 18 | "cell_type": "markdown", 19 | "metadata": {}, 20 | "source": [ 21 | "The goal of this notebook is to demonstrate the capabilities of the Python Software Development Kit for Brandwatch's API. The SDK was designed to address many of the challenges involved in building complex applications which interact with RESTful API's in general and Brandwatch's API in particular:\n", 22 | "" 28 | ] 29 | }, 30 | { 31 | "cell_type": "markdown", 32 | "metadata": {}, 33 | "source": [ 34 | "From the user's perspective, the basic structure of the SDK is as follows. One first creates an instance of the class `BWProject`; this class handles authentication (via a user name and password or API key) and keeps track of project-level data such as the project's ID. (Behind the scenes, the user-level operations are handled by the class `BWUser` from which `BWProject` is inherited.) One passes `BWProject` instance as an argument in the constructor for a series of classes which manage the various Brandwatch resources: queries, groups, tags, categories, etc. These resource classes manage all resource-level operations: for example a single `BWQueries` instance handles all HTTP requests associated with queries in its attached project." 35 | ] 36 | }, 37 | { 38 | "cell_type": "markdown", 39 | "metadata": {}, 40 | "source": [ 41 | "Typically, you'd import only the classes you plan on using, but for this demo all classes are listed except for superclasses which you do not use explicitly)" 42 | ] 43 | }, 44 | { 45 | "cell_type": "code", 46 | "execution_count": null, 47 | "metadata": {}, 48 | "outputs": [], 49 | "source": [ 50 | "from bwapi.bwproject import BWProject, BWUser\n", 51 | "from bwapi.bwresources import BWQueries, BWGroups, BWAuthorLists, BWSiteLists, BWLocationLists, BWTags, BWCategories, BWRules, BWMentions, BWSignals\n", 52 | "import datetime" 53 | ] 54 | }, 55 | { 56 | "cell_type": "markdown", 57 | "metadata": {}, 58 | "source": [ 59 | "The SDK uses the Python logging module to tell you what it's doing; if desired you can control what sort of output you see by uncommenting one of the lines below:" 60 | ] 61 | }, 62 | { 63 | "cell_type": "code", 64 | "execution_count": null, 65 | "metadata": { 66 | "collapsed": true 67 | }, 68 | "outputs": [], 69 | "source": [ 70 | "import logging\n", 71 | "logger = logging.getLogger(\"bwapi\")\n", 72 | "\n", 73 | "#(Default) All logging messages enabled\n", 74 | "#logger.setLevel(logging.DEBUG)\n", 75 | "\n", 76 | "#Does not report URL's of API requests, but all other messages enabled\n", 77 | "#logger.setLevel(logging.INFO)\n", 78 | "\n", 79 | "#Report only errors and warnings\n", 80 | "#logger.setLevel(logging.WARN)\n", 81 | "\n", 82 | "#Report only errors\n", 83 | "#logger.setLevel(logging.ERROR)\n", 84 | "\n", 85 | "#Disable logging\n", 86 | "#logger.setLevel(logging.CRITICAL)" 87 | ] 88 | }, 89 | { 90 | "cell_type": "markdown", 91 | "metadata": {}, 92 | "source": [ 93 | "## Project" 94 | ] 95 | }, 96 | { 97 | "cell_type": "markdown", 98 | "metadata": {}, 99 | "source": [ 100 | "When you use the API for the first time you have to authenticate with Brandwatch. This will get you an access token. The access token is stored in a credentials file (`tokens.txt` in this example). Once you've authenticated your access token will be read from that file so you won't need to enter your password again.\n", 101 | "\n", 102 | "You can authenticate from command line using the provided console script `bwapi-authenticate`:\n", 103 | "\n", 104 | "```\n", 105 | "$ bwapi-authenticate\n", 106 | "Please enter your Brandwatch credentials below\n", 107 | "Username: example@example\n", 108 | "Password:\n", 109 | "Authenticating user: example@example\n", 110 | "Writing access token for user: example@example\n", 111 | "Writing access token for user: example@example\n", 112 | "Success! Access token: 00000000-0000-0000-0000-000000000000\n", 113 | "```\n", 114 | "\n", 115 | "Alternatively, you can authenticate directly:" 116 | ] 117 | }, 118 | { 119 | "cell_type": "code", 120 | "execution_count": null, 121 | "metadata": {}, 122 | "outputs": [], 123 | "source": [ 124 | "BWUser(username=\"user@example.com\", password=\"YOUR_PASSWORD\", token_path=\"tokens.txt\")" 125 | ] 126 | }, 127 | { 128 | "cell_type": "markdown", 129 | "metadata": {}, 130 | "source": [ 131 | "Now you have authenticated you can load your project:" 132 | ] 133 | }, 134 | { 135 | "cell_type": "code", 136 | "execution_count": null, 137 | "metadata": {}, 138 | "outputs": [], 139 | "source": [ 140 | "YOUR_ACCOUNT = your_account\n", 141 | "YOUR_PROJECT = your_project\n", 142 | "\n", 143 | "project = BWProject(username=YOUR_ACCOUNT, project=YOUR_PROJECT)" 144 | ] 145 | }, 146 | { 147 | "cell_type": "markdown", 148 | "metadata": {}, 149 | "source": [ 150 | "Before we really begin, please note that you can get documentation for any class or function by viewing the help documentation" 151 | ] 152 | }, 153 | { 154 | "cell_type": "code", 155 | "execution_count": null, 156 | "metadata": {}, 157 | "outputs": [], 158 | "source": [ 159 | "help(BWProject)" 160 | ] 161 | }, 162 | { 163 | "cell_type": "markdown", 164 | "metadata": {}, 165 | "source": [ 166 | "## Queries" 167 | ] 168 | }, 169 | { 170 | "cell_type": "markdown", 171 | "metadata": {}, 172 | "source": [ 173 | "Now we create some objects which can manipulate queries and groups in our project:" 174 | ] 175 | }, 176 | { 177 | "cell_type": "code", 178 | "execution_count": null, 179 | "metadata": {}, 180 | "outputs": [], 181 | "source": [ 182 | "queries = BWQueries(project)" 183 | ] 184 | }, 185 | { 186 | "cell_type": "markdown", 187 | "metadata": {}, 188 | "source": [ 189 | "Let's check what queries already exist in the account" 190 | ] 191 | }, 192 | { 193 | "cell_type": "code", 194 | "execution_count": null, 195 | "metadata": {}, 196 | "outputs": [], 197 | "source": [ 198 | "queries.names" 199 | ] 200 | }, 201 | { 202 | "cell_type": "markdown", 203 | "metadata": {}, 204 | "source": [ 205 | "We can also upload queries directly via the API by handing the \"name\", \"searchTerms\" and \"backfillDate\" to the upload funcion. If you don't pass a backfillDate, then the query will not backfill.\n", 206 | "\n", 207 | "The BWQueries class inserts default values for the \"languages\", \"type\", \"industry\", and \"samplePercent\" parameters, but we can override the defaults by including them as keyword arguments if we want. \n", 208 | "\n", 209 | "Upload accepts two boolean keyword arguments - \"create_only\" and \"modify_only\" (both defaulting to False) - which specifies what API verbs the function is allowed to use; for instance, if we set \"create_only\" to True then the function will post a new query if it can and otherwise it will do nothing. Note: this is true of all upload functions in this package." 210 | ] 211 | }, 212 | { 213 | "cell_type": "code", 214 | "execution_count": null, 215 | "metadata": {}, 216 | "outputs": [], 217 | "source": [ 218 | "queries.upload(name = \"Brandwatch Engagement\", \n", 219 | " includedTerms = \"at_mentions:Brandwatch\",\n", 220 | " backfill_date = \"2015-09-01\")" 221 | ] 222 | }, 223 | { 224 | "cell_type": "markdown", 225 | "metadata": {}, 226 | "source": [ 227 | "If you're uploading many queries at a time, you can upload in batches. This saves API calls and allows you to just pass in a list rather than iterating over the upload function." 228 | ] 229 | }, 230 | { 231 | "cell_type": "code", 232 | "execution_count": null, 233 | "metadata": {}, 234 | "outputs": [], 235 | "source": [ 236 | "queries.upload_all([\n", 237 | " {\"name\":\"Pets\", \n", 238 | " \"includedTerms\":\"dogs OR cats\", \n", 239 | " \"backfill_date\":\"2016-01-01T05:00:00\"}, \n", 240 | " \n", 241 | " {\"name\":\"ice cream cake\", \n", 242 | " \"includedTerms\":\"(\\\"ice cream\\\" OR icecream) AND (cake)\"},\n", 243 | " \n", 244 | " {\"name\": \"Test1\",\n", 245 | " \"includedTerms\": \"akdnvaoifg;anf\"},\n", 246 | " \n", 247 | " {\"name\": \"Test2\",\n", 248 | " \"includedTerms\": \"anvoapihajkvn\"},\n", 249 | " \n", 250 | " {\"name\": \"Test3\",\n", 251 | " \"includedTerms\": \"nviuphabaveh\"},\n", 252 | "\n", 253 | " ])" 254 | ] 255 | }, 256 | { 257 | "cell_type": "markdown", 258 | "metadata": {}, 259 | "source": [ 260 | "We can delete queries one at a time, or in batches." 261 | ] 262 | }, 263 | { 264 | "cell_type": "code", 265 | "execution_count": null, 266 | "metadata": {}, 267 | "outputs": [], 268 | "source": [ 269 | "queries.delete(name = \"Brandwatch Engagement\")\n", 270 | "queries.delete_all([\"Pets\", \"Test3\", \"Brandwatch\", \"BWReact\", \"Brandwatch Careers\"])" 271 | ] 272 | }, 273 | { 274 | "cell_type": "markdown", 275 | "metadata": {}, 276 | "source": [ 277 | "## Groups" 278 | ] 279 | }, 280 | { 281 | "cell_type": "markdown", 282 | "metadata": {}, 283 | "source": [ 284 | "You'll notice that a lot of the things that were true for queries are also true for groups. Many of the functions are nearly identical with any adaptations necessary handled behind the scenes for ease of use.\n", 285 | "\n", 286 | "Again (as with queries), we need to create an object with which we can manipulate groups within the account" 287 | ] 288 | }, 289 | { 290 | "cell_type": "code", 291 | "execution_count": null, 292 | "metadata": {}, 293 | "outputs": [], 294 | "source": [ 295 | "groups = BWGroups(project)" 296 | ] 297 | }, 298 | { 299 | "cell_type": "markdown", 300 | "metadata": {}, 301 | "source": [ 302 | "And can check for exisiting groups in the same way as before." 303 | ] 304 | }, 305 | { 306 | "cell_type": "code", 307 | "execution_count": null, 308 | "metadata": {}, 309 | "outputs": [], 310 | "source": [ 311 | "groups.names" 312 | ] 313 | }, 314 | { 315 | "cell_type": "markdown", 316 | "metadata": {}, 317 | "source": [ 318 | "Now let's check which queries are in each group in the account" 319 | ] 320 | }, 321 | { 322 | "cell_type": "code", 323 | "execution_count": null, 324 | "metadata": {}, 325 | "outputs": [], 326 | "source": [ 327 | "for group in groups.names:\n", 328 | " print(group)\n", 329 | " print(groups.get_group_queries(group))\n", 330 | " print()" 331 | ] 332 | }, 333 | { 334 | "cell_type": "markdown", 335 | "metadata": {}, 336 | "source": [ 337 | "We can easily create a group with any preexisting queries.\n", 338 | "\n", 339 | "(Recall that upload accepts two boolean keyword arguments - \"create_only\" and \"modify_only\" (both defaulting to False) - which specifies what API verbs the function is allowed to use; for instance, if we set \"create_only\" to True then the function will post a new query if it can and otherwise it will do nothing.)" 340 | ] 341 | }, 342 | { 343 | "cell_type": "code", 344 | "execution_count": null, 345 | "metadata": {}, 346 | "outputs": [], 347 | "source": [ 348 | "groups.upload(name = \"group 1\", queries = [\"Test1\", \"Test2\"])" 349 | ] 350 | }, 351 | { 352 | "cell_type": "markdown", 353 | "metadata": {}, 354 | "source": [ 355 | "Or upload new queries and create a group with them, all in one call" 356 | ] 357 | }, 358 | { 359 | "cell_type": "code", 360 | "execution_count": null, 361 | "metadata": {}, 362 | "outputs": [], 363 | "source": [ 364 | "groups.upload_queries_as_group(group_name = \"group 2\", \n", 365 | " query_data_list = [{\"name\": \"Test3\",\n", 366 | " \"includedTerms\": \"adcioahnanva\"},\n", 367 | " \n", 368 | " {\"name\": \"Test4\",\n", 369 | " \"includedTerms\": \"ioanvauhekanv;\"}])" 370 | ] 371 | }, 372 | { 373 | "cell_type": "markdown", 374 | "metadata": {}, 375 | "source": [ 376 | "We can either delete just the group, or delete the group and the queries at the same time." 377 | ] 378 | }, 379 | { 380 | "cell_type": "code", 381 | "execution_count": null, 382 | "metadata": {}, 383 | "outputs": [], 384 | "source": [ 385 | "groups.delete(\"group 1\")\n", 386 | "print()\n", 387 | "groups.deep_delete(\"group 2\")" 388 | ] 389 | }, 390 | { 391 | "cell_type": "markdown", 392 | "metadata": {}, 393 | "source": [ 394 | "## Downloading Mentions (From a Query or a Group)" 395 | ] 396 | }, 397 | { 398 | "cell_type": "markdown", 399 | "metadata": {}, 400 | "source": [ 401 | "You can download mentions from a Query or from a Group (the code does not yet support Channels)\n", 402 | "\n", 403 | "There is a function get_mentions() in the classes BWQueries and in BWGroups. They are used the same way.\n", 404 | "\n", 405 | "Be careful with time zones, as they affect the date range and alter the results. If you're using the same date range for all your operations, I reccomend setting some variables at the start with dates and time zones. \n", 406 | "\n", 407 | "Here, today is set to the current day, and start is set to 30 days ago. Each number is offset by one to make it accurate." 408 | ] 409 | }, 410 | { 411 | "cell_type": "code", 412 | "execution_count": null, 413 | "metadata": { 414 | "collapsed": true 415 | }, 416 | "outputs": [], 417 | "source": [ 418 | "today = (datetime.date.today() + datetime.timedelta(days=1)).isoformat() + \"T05:00:00\"\n", 419 | "start = (datetime.date.today() - datetime.timedelta(days=29)).isoformat() + \"T05:00:00\"" 420 | ] 421 | }, 422 | { 423 | "cell_type": "markdown", 424 | "metadata": {}, 425 | "source": [ 426 | "To use get_mentions(), the minimum parameters needed are name (query name in this case, or group name if downloading mentions from a group), startDate, and endDate" 427 | ] 428 | }, 429 | { 430 | "cell_type": "code", 431 | "execution_count": null, 432 | "metadata": {}, 433 | "outputs": [], 434 | "source": [ 435 | "filtered = queries.get_mentions(name = \"ice cream cake\",\n", 436 | " startDate = start, \n", 437 | " endDate = today)" 438 | ] 439 | }, 440 | { 441 | "cell_type": "markdown", 442 | "metadata": {}, 443 | "source": [ 444 | "There are over a hundred filters you can use to only download the mentions that qualify. see the full list in the file filters.py\n", 445 | "\n", 446 | "Here, different filters are used, which take different data types. filters.py details which data type is used with each filter. Some filters, like sentiment and xprofession below, have a limited number of settings to choose from.\n", 447 | "\n", 448 | "You can filter many things by inclusion or exclusion. The x in xprofession stands for exclusion, for example." 449 | ] 450 | }, 451 | { 452 | "cell_type": "code", 453 | "execution_count": null, 454 | "metadata": {}, 455 | "outputs": [], 456 | "source": [ 457 | "filtered = queries.get_mentions(name = \"ice cream cake\", \n", 458 | " startDate = start, \n", 459 | " endDate = today, \n", 460 | " sentiment = \"positive\", \n", 461 | " twitterVerified = False, \n", 462 | " impactMin = 50, \n", 463 | " xprofession = [\"Politician\", \"Legal\"])" 464 | ] 465 | }, 466 | { 467 | "cell_type": "markdown", 468 | "metadata": {}, 469 | "source": [ 470 | "To filter by tags, pass in a list of strings where each string is a tag name.\n", 471 | "\n", 472 | "You can filter by categories in two differnt ways: on a subcategory level or a parent category level. To filter on a subcategory level, use the category keyword and pass in a dictionary, where each the keys are the parent categories and the values are lists of the subcategories. To filter on a parent category level, use the parentCategory keyword and pass in a list of parent category names.\n", 473 | "\n", 474 | "Note: In the following call the parentCategory filter is redundant, but executed for illustrative purposes." 475 | ] 476 | }, 477 | { 478 | "cell_type": "code", 479 | "execution_count": null, 480 | "metadata": {}, 481 | "outputs": [], 482 | "source": [ 483 | "filtered = queries.get_mentions(name = \"ice cream cake\", \n", 484 | " startDate = start, \n", 485 | " endDate = today,\n", 486 | " parentCategory = [\"Colors\", \"Days\"],\n", 487 | " category = {\"Colors\": [\"Blue\", \"Yellow\"], \n", 488 | " \"Days\": [\"Monday\"]}, \n", 489 | " tag = [\"Tastes Good\"])" 490 | ] 491 | }, 492 | { 493 | "cell_type": "code", 494 | "execution_count": null, 495 | "metadata": {}, 496 | "outputs": [], 497 | "source": [ 498 | "filtered[0]" 499 | ] 500 | }, 501 | { 502 | "cell_type": "markdown", 503 | "metadata": {}, 504 | "source": [ 505 | "## Categories" 506 | ] 507 | }, 508 | { 509 | "cell_type": "markdown", 510 | "metadata": {}, 511 | "source": [ 512 | "Instantiate a BWCategories object by passing in your project as a parameter, which loads all of the categories in your project.\n", 513 | "\n", 514 | "Print out ids to see which categories are currently in your project. " 515 | ] 516 | }, 517 | { 518 | "cell_type": "code", 519 | "execution_count": null, 520 | "metadata": {}, 521 | "outputs": [], 522 | "source": [ 523 | "categories = BWCategories(project)\n", 524 | "\n", 525 | "categories.ids" 526 | ] 527 | }, 528 | { 529 | "cell_type": "markdown", 530 | "metadata": {}, 531 | "source": [ 532 | "Upload categories individually with upload(), or in bulk with upload_all(). If you are uploading many categories, it is more efficient to use upload_all().\n", 533 | "\n", 534 | "For upload(), pass in name and children. name is the string which represents the parent category, and children is a list of dictionaries where each dictionary is a child category- its key is \"name\" and its value is the name of the child category.\n", 535 | "\n", 536 | "By default, a category will allow multiple subcategories to be applies, so the keyword argument \"multiple\" is set to True. You can manually set it to False by passing in multipe=False as another parameter when uploading a category.\n", 537 | "\n", 538 | "For upload_all(), pass in a list of dictionaries, where each dictionary corrosponds to one category, and contains the parameters described above." 539 | ] 540 | }, 541 | { 542 | "cell_type": "markdown", 543 | "metadata": {}, 544 | "source": [ 545 | "Let's upload a category and then check what's in the category." 546 | ] 547 | }, 548 | { 549 | "cell_type": "code", 550 | "execution_count": null, 551 | "metadata": {}, 552 | "outputs": [], 553 | "source": [ 554 | "categories.upload(name = \"Droids\", \n", 555 | " children = [\"r2d2\", \"c3po\"])" 556 | ] 557 | }, 558 | { 559 | "cell_type": "markdown", 560 | "metadata": {}, 561 | "source": [ 562 | "Now let's upload a few categories and then check what parent categories are in the system" 563 | ] 564 | }, 565 | { 566 | "cell_type": "code", 567 | "execution_count": null, 568 | "metadata": { 569 | "scrolled": true 570 | }, 571 | "outputs": [], 572 | "source": [ 573 | "categories.upload_all([{\"name\":\"month\", \n", 574 | " \"children\":[\"January\",\"February\"]}, \n", 575 | " {\"name\":\"Time of Day\", \n", 576 | " \"children\":[\"morning\", \"evening\"]}])" 577 | ] 578 | }, 579 | { 580 | "cell_type": "markdown", 581 | "metadata": {}, 582 | "source": [ 583 | "To add children/subcategories, call upload() and pass in the parent category name and a list of the new subcategories to add. \n", 584 | "\n", 585 | "If you'd like to instead overwrite the existing subcategories with new subcategories, call upload() and pass in the parameter overwrite_children = True." 586 | ] 587 | }, 588 | { 589 | "cell_type": "code", 590 | "execution_count": null, 591 | "metadata": {}, 592 | "outputs": [], 593 | "source": [ 594 | "categories.upload(name = \"Droids\", children = [\"bb8\"])" 595 | ] 596 | }, 597 | { 598 | "cell_type": "markdown", 599 | "metadata": {}, 600 | "source": [ 601 | "To rename a category, call rename(), with parameters name and new_name." 602 | ] 603 | }, 604 | { 605 | "cell_type": "code", 606 | "execution_count": null, 607 | "metadata": {}, 608 | "outputs": [], 609 | "source": [ 610 | "categories.rename(name = \"month\", new_name = \"Months\")\n", 611 | "categories.ids[\"Months\"]" 612 | ] 613 | }, 614 | { 615 | "cell_type": "markdown", 616 | "metadata": {}, 617 | "source": [ 618 | "You can delete categories either individually with delete(), or in bulk with delete_all(). \n", 619 | "\n", 620 | "You also have the option to delete the entire parent category or just some of the subcategories. \n", 621 | "\n", 622 | "To delete ALL CATEGORIES in a project, call clear_all_in_project with no parameters. Be careful with this one, and do not use unless you want to delete all categories in the current project." 623 | ] 624 | }, 625 | { 626 | "cell_type": "markdown", 627 | "metadata": {}, 628 | "source": [ 629 | "First let's delete just some subcategories." 630 | ] 631 | }, 632 | { 633 | "cell_type": "code", 634 | "execution_count": null, 635 | "metadata": {}, 636 | "outputs": [], 637 | "source": [ 638 | "categories.delete({\"name\": \"Months\", \"children\":[\"February\"]})\n", 639 | "categories.delete_all([{\"name\": \"Droids\", \"children\": [\"bb8\", \"c3po\"]}])" 640 | ] 641 | }, 642 | { 643 | "cell_type": "code", 644 | "execution_count": null, 645 | "metadata": {}, 646 | "outputs": [], 647 | "source": [ 648 | "categories.delete(\"Droids\")\n", 649 | "categories.delete_all([\"Months\", \"Time of Day\"])\n", 650 | "\n", 651 | "categories.ids" 652 | ] 653 | }, 654 | { 655 | "cell_type": "markdown", 656 | "metadata": {}, 657 | "source": [ 658 | "## Tags" 659 | ] 660 | }, 661 | { 662 | "cell_type": "markdown", 663 | "metadata": {}, 664 | "source": [ 665 | "Instantiate a BWTags object by passing in your project as a parameter, which loads all of the tags in your project.\n", 666 | "\n", 667 | "Print out ids to see which tags are currently in your project. " 668 | ] 669 | }, 670 | { 671 | "cell_type": "code", 672 | "execution_count": null, 673 | "metadata": {}, 674 | "outputs": [], 675 | "source": [ 676 | "tags = BWTags(project)\n", 677 | "\n", 678 | "tags.names" 679 | ] 680 | }, 681 | { 682 | "cell_type": "markdown", 683 | "metadata": {}, 684 | "source": [ 685 | "There are two ways to upload tags: individually and in bulk. When uploading many tags, it is more efficient to use upload_all. \n", 686 | "\n", 687 | "In upload, pass in the name of the tag.\n", 688 | "\n", 689 | "In upload_all, pass in a list of dictionaries, where each dictionary contains \"name\" as the key and the tag name as the its value" 690 | ] 691 | }, 692 | { 693 | "cell_type": "code", 694 | "execution_count": null, 695 | "metadata": {}, 696 | "outputs": [], 697 | "source": [ 698 | "tags.upload(name = \"yellow\")\n", 699 | "tags.upload_all([{\"name\":\"green\"}, \n", 700 | " {\"name\":\"blue\"}, \n", 701 | " {\"name\":\"purple\"}])\n", 702 | "\n", 703 | "tags.names" 704 | ] 705 | }, 706 | { 707 | "cell_type": "markdown", 708 | "metadata": {}, 709 | "source": [ 710 | "To change the name of a tag, but mantain its id, upload it with keyword arguments name and new_name. " 711 | ] 712 | }, 713 | { 714 | "cell_type": "code", 715 | "execution_count": null, 716 | "metadata": {}, 717 | "outputs": [], 718 | "source": [ 719 | "tags.upload(name = \"yellow\", new_name = \"yellow-orange blend\")\n", 720 | "\n", 721 | "tags.names" 722 | ] 723 | }, 724 | { 725 | "cell_type": "markdown", 726 | "metadata": {}, 727 | "source": [ 728 | "As with categories, there are three ways of deleting tags. \n", 729 | "\n", 730 | "Delete one tag by calling delete and passing in a string, the name of the tag to delete\n", 731 | "\n", 732 | "Delete multiple tags by calling delete_all and passing in a list of strings, where each string is a name of a tag to delete\n", 733 | "\n", 734 | "To delete ALL TAGS in a project, call clear_all_in_project with no parameters. Be careful with this one, and do not use unless you want to delete all tags in the current project" 735 | ] 736 | }, 737 | { 738 | "cell_type": "code", 739 | "execution_count": null, 740 | "metadata": {}, 741 | "outputs": [], 742 | "source": [ 743 | "tags.delete(\"purple\")\n", 744 | "tags.delete_all([\"blue\", \"green\", \"yellow-orange blend\"])\n", 745 | "\n", 746 | "tags.names" 747 | ] 748 | }, 749 | { 750 | "cell_type": "markdown", 751 | "metadata": {}, 752 | "source": [ 753 | "## Brandwatch Lists" 754 | ] 755 | }, 756 | { 757 | "cell_type": "markdown", 758 | "metadata": {}, 759 | "source": [ 760 | "Note: to avoid ambiguity between the python data type \"list\" and a Brandwatch author list, site list, or location list, the latter is referred to in this demo as a \"Brandwatch List.\"\n", 761 | "\n", 762 | "BWAuthorLists, BWSiteLists, BWLocationLists work almost identically.\n", 763 | "\n", 764 | "First, instantiate your the object which contains the Brandwatch Lists in your project, with your project as a the parameter. This will load the data from your project so you can see what's there, upload more Brandwatch Lists, edit existing Brandwatch Lists, and delete Brandwatch Lists from your project\n", 765 | "\n", 766 | "Printing out ids will show you the Brandwatch Lists (by name and ID) that are currently in your project." 767 | ] 768 | }, 769 | { 770 | "cell_type": "code", 771 | "execution_count": null, 772 | "metadata": {}, 773 | "outputs": [], 774 | "source": [ 775 | "authorlists = BWAuthorLists(project)\n", 776 | "authorlists.names" 777 | ] 778 | }, 779 | { 780 | "cell_type": "markdown", 781 | "metadata": {}, 782 | "source": [ 783 | "To upload a Brandwatch List, pass in a name as a string and the contents of your Brandwatch List as a list of strings. The keyword \"authors\" is used for BWAuthorLists, shown below. The keyword \"domains\"is used for BWSiteLists. The keyword \"locations\" is used for BWLocationLists.\n", 784 | "\n", 785 | "To see the contents of a Brandwatch List, call get_list with the name as the parameter\n", 786 | "\n", 787 | "Uploading is done with either a POST call, for new Brandwatch Lists, or a PUT call, for existing Brandwatch Lists, where the ID of the Brandwatch Lists is mantained, so if you upload and then upload a list with the same name and different contents, the first upload will create a new Brandwatch List, and the second upload will modify the existing list and keep its ID. Similarly, you can change the name of an existing Brandwatch List by passing in both \"name\" and \"new_name\"" 788 | ] 789 | }, 790 | { 791 | "cell_type": "code", 792 | "execution_count": null, 793 | "metadata": {}, 794 | "outputs": [], 795 | "source": [ 796 | "authorlists.upload(name = \"Writers\", \n", 797 | " authors = [\"Edward Albee\", \"Tenessee Williams\", \"Anna Deavere Smith\"])\n", 798 | "\n", 799 | "authorlists.get(\"Writers\")[\"authors\"]" 800 | ] 801 | }, 802 | { 803 | "cell_type": "code", 804 | "execution_count": null, 805 | "metadata": {}, 806 | "outputs": [], 807 | "source": [ 808 | "authorlists.upload(name = \"Writers\", \n", 809 | " new_name = \"Playwrights\", \n", 810 | " authors = [\"Edward Albee\", \"Tenessee Williams\", \"Anna Deavere Smith\", \"Susan Glaspell\"])\n", 811 | "\n", 812 | "authorlists.get(\"Playwrights\")[\"authors\"]" 813 | ] 814 | }, 815 | { 816 | "cell_type": "markdown", 817 | "metadata": {}, 818 | "source": [ 819 | "To add items to a Brandwatch List without reentering all of the existing items, call add_items " 820 | ] 821 | }, 822 | { 823 | "cell_type": "code", 824 | "execution_count": null, 825 | "metadata": {}, 826 | "outputs": [], 827 | "source": [ 828 | "authorlists.add_items(name = \"Playwrights\", \n", 829 | " items = [\"Eugene O'Neill\"])\n", 830 | "\n", 831 | "authorlists.get(\"Playwrights\")[\"authors\"]" 832 | ] 833 | }, 834 | { 835 | "cell_type": "markdown", 836 | "metadata": {}, 837 | "source": [ 838 | "To delete a Brandwatch List, pass in its name. Note the ids before the Brandwatch List is deleted, compared to after it is deleted. The BWLists object is updated to reflect the Brandwatch Lists in the project after each upload and each delete" 839 | ] 840 | }, 841 | { 842 | "cell_type": "code", 843 | "execution_count": null, 844 | "metadata": {}, 845 | "outputs": [], 846 | "source": [ 847 | "authorlists.names" 848 | ] 849 | }, 850 | { 851 | "cell_type": "code", 852 | "execution_count": null, 853 | "metadata": {}, 854 | "outputs": [], 855 | "source": [ 856 | "authorlists.delete(\"Playwrights\")\n", 857 | "\n", 858 | "authorlists.names" 859 | ] 860 | }, 861 | { 862 | "cell_type": "markdown", 863 | "metadata": {}, 864 | "source": [ 865 | "The only difference between how you use BWAuthorlists compared to how you use BWSiteLists and BWLocationLists is the parameter which is passed in. \n", 866 | "\n", 867 | "BWAuthorlists:\n", 868 | "\n", 869 | "authors = [\"edward albee\", \"tenessee williams\", \"Anna Deavere Smith\"]\n", 870 | "\n", 871 | "BWSiteLists:\n", 872 | "\n", 873 | "domains = [\"github.com\", \"stackoverflow.com\", \"docs.python.org\"]\n", 874 | "\n", 875 | "*BWLocationLists:\n", 876 | "\n", 877 | "locations = [{\"id\": \"mai4\", \"name\": \"Maine\", \"type\": \"state\", \"fullName\": \"Maine, United States, North America\"}, \n", 878 | "{\"id\": \"verf\", \"name\": \"Vermont\", \"type\": \"state\", \"fullName\": \"Vermont, United States, North America\"}, \n", 879 | "{\"id\": \"rho4\", \"name\": \"Rhode Island\", \"type\": \"state\", \"fullName\": \"Rhode Island, United States, North America\"} ]\n", 880 | "\n", 881 | "*Requires dictionary of location data instead of a string\n" 882 | ] 883 | }, 884 | { 885 | "cell_type": "markdown", 886 | "metadata": {}, 887 | "source": [ 888 | "## Rules" 889 | ] 890 | }, 891 | { 892 | "cell_type": "markdown", 893 | "metadata": {}, 894 | "source": [ 895 | "Instantiate a BWRules object by passing in your project as a parameter, which loads all of the rules in your project.\n", 896 | "\n", 897 | "Print out names and IDs to see which rules are currently in your project. " 898 | ] 899 | }, 900 | { 901 | "cell_type": "code", 902 | "execution_count": null, 903 | "metadata": {}, 904 | "outputs": [], 905 | "source": [ 906 | "rules = BWRules(project)\n", 907 | "rules.names" 908 | ] 909 | }, 910 | { 911 | "cell_type": "markdown", 912 | "metadata": {}, 913 | "source": [ 914 | "Every rule must have a name, an action, and filters.\n", 915 | "\n", 916 | "The first step to creating a rule through the API is to prepare filters by calling filters(). \n", 917 | "\n", 918 | "If your desired rules applies to a query (or queries), include queryName as a filter and pass in a list of the queries you want to apply it to.\n", 919 | "\n", 920 | "There are over a hundred filters you can use to only download the mentions that qualify. See the full list in the file filters.py. Here, different filters are used, which take different data types. filters.py details which data type is used with each filter. Some filters, like sentiment and xprofession below, have a limited number of settings to choose from. You can filter many things by inclusion or exclusion. The x in xprofession stands for exclusion, for example.\n", 921 | "\n", 922 | "If you include search terms, be sure to use nested quotes - passing in \"cat food\" will result in a search that says cat food (i.e. cat AND food)" 923 | ] 924 | }, 925 | { 926 | "cell_type": "code", 927 | "execution_count": null, 928 | "metadata": {}, 929 | "outputs": [], 930 | "source": [ 931 | "filters = rules.filters(queryName = \"ice cream cake\", \n", 932 | " sentiment = \"positive\", \n", 933 | " twitterVerified = False, \n", 934 | " impactMin = 50, \n", 935 | " xprofession = [\"Politician\", \"Legal\"])" 936 | ] 937 | }, 938 | { 939 | "cell_type": "code", 940 | "execution_count": null, 941 | "metadata": {}, 942 | "outputs": [], 943 | "source": [ 944 | "filters = rules.filters(queryName = [\"Australian Animals\", \"ice cream cake\"], \n", 945 | " search = '\"cat food\" OR \"dog food\"')" 946 | ] 947 | }, 948 | { 949 | "cell_type": "markdown", 950 | "metadata": {}, 951 | "source": [ 952 | "The second step is to prepare the rule action by calling rule_action().\n", 953 | "\n", 954 | "For this function, you must pass in the action and setting. Below I've used examples of adding categories and tags, but you can also set sentiment or workflow (as in the front end).\n", 955 | "\n", 956 | "If you pass in a category or tag that does not yet exist, it will be automatically uploaded for you." 957 | ] 958 | }, 959 | { 960 | "cell_type": "code", 961 | "execution_count": null, 962 | "metadata": {}, 963 | "outputs": [], 964 | "source": [ 965 | "action = rules.rule_action(action = \"addTag\", \n", 966 | " setting = [\"animal food\"])" 967 | ] 968 | }, 969 | { 970 | "cell_type": "markdown", 971 | "metadata": {}, 972 | "source": [ 973 | "The last step is to upload!\n", 974 | "\n", 975 | "Pass in the name, filters, and action. Scope is optional - it will default to query if queryName is in the filters and otherwise be set to project. Backfill is also optional - it will default to False.\n", 976 | "\n", 977 | "The upload() function will automatically check the validity of your search string and give a helpful error message if errors are found." 978 | ] 979 | }, 980 | { 981 | "cell_type": "code", 982 | "execution_count": null, 983 | "metadata": {}, 984 | "outputs": [], 985 | "source": [ 986 | "rules.upload(name = \"rule\", \n", 987 | " scope = \"query\", \n", 988 | " filter = filters, \n", 989 | " ruleAction = action,\n", 990 | " backfill = True)" 991 | ] 992 | }, 993 | { 994 | "cell_type": "markdown", 995 | "metadata": {}, 996 | "source": [ 997 | "You can also upload rules in bulk. Below we prepare a bunch of filters and actions at once." 998 | ] 999 | }, 1000 | { 1001 | "cell_type": "code", 1002 | "execution_count": null, 1003 | "metadata": {}, 1004 | "outputs": [], 1005 | "source": [ 1006 | "filters1 = rules.filters(search = \"caknvfoga;vnaei\")\n", 1007 | "filters2 = rules.filters(queryName = [\"Australian Animals\"], search = \"(bloop NEAR/10 blorp)\")\n", 1008 | "filters3 = rules.filters(queryName = [\"Australian Animals\", \"ice cream cake\"], search = '\"hello world\"')\n", 1009 | "\n", 1010 | "action1 = rules.rule_action(action = \"addCategories\", setting = {\"Example\": [\"One\"]})\n", 1011 | "action2 = rules.rule_action(action = \"addTag\", setting = [\"My Example\"])" 1012 | ] 1013 | }, 1014 | { 1015 | "cell_type": "markdown", 1016 | "metadata": {}, 1017 | "source": [ 1018 | "When uploading in bulk, it is helpful (but not necessary) to use the rules() function before uploading in order to keep the dictionaries organized." 1019 | ] 1020 | }, 1021 | { 1022 | "cell_type": "code", 1023 | "execution_count": null, 1024 | "metadata": {}, 1025 | "outputs": [], 1026 | "source": [ 1027 | "rule1 = rules.rule(name = \"rule1\", \n", 1028 | " filter = filters1, \n", 1029 | " action = action1, \n", 1030 | " scope = \"project\")\n", 1031 | "\n", 1032 | "rule2 = rules.rule(name = \"rule2\", \n", 1033 | " filter = filters2, \n", 1034 | " action = action2)\n", 1035 | "\n", 1036 | "rule3 = rules.rule(name = \"rule3\", \n", 1037 | " filter = filters3, \n", 1038 | " action = action1,\n", 1039 | " backfill = True)" 1040 | ] 1041 | }, 1042 | { 1043 | "cell_type": "code", 1044 | "execution_count": null, 1045 | "metadata": {}, 1046 | "outputs": [], 1047 | "source": [ 1048 | "rules.upload_all([rule1, rule2, rule3])" 1049 | ] 1050 | }, 1051 | { 1052 | "cell_type": "markdown", 1053 | "metadata": {}, 1054 | "source": [ 1055 | "As with other resources, we can delete, delete_all or clear_all_in_project" 1056 | ] 1057 | }, 1058 | { 1059 | "cell_type": "code", 1060 | "execution_count": null, 1061 | "metadata": {}, 1062 | "outputs": [], 1063 | "source": [ 1064 | "rules.delete(name = \"rule\")\n", 1065 | "rules.delete_all(names = [\"rule1\", \"rule2\", \"rule3\"])\n", 1066 | "\n", 1067 | "rules.names" 1068 | ] 1069 | }, 1070 | { 1071 | "cell_type": "markdown", 1072 | "metadata": {}, 1073 | "source": [ 1074 | "## Signals" 1075 | ] 1076 | }, 1077 | { 1078 | "cell_type": "markdown", 1079 | "metadata": {}, 1080 | "source": [ 1081 | "Instantiate a BWSignals object by passing in your project as a parameter, which loads all of the signals in your project.\n", 1082 | "\n", 1083 | "Print out ids to see which signals are currently in your project." 1084 | ] 1085 | }, 1086 | { 1087 | "cell_type": "code", 1088 | "execution_count": null, 1089 | "metadata": {}, 1090 | "outputs": [], 1091 | "source": [ 1092 | "signals = BWSignals(project)" 1093 | ] 1094 | }, 1095 | { 1096 | "cell_type": "code", 1097 | "execution_count": null, 1098 | "metadata": {}, 1099 | "outputs": [], 1100 | "source": [ 1101 | "signals.names" 1102 | ] 1103 | }, 1104 | { 1105 | "cell_type": "markdown", 1106 | "metadata": {}, 1107 | "source": [ 1108 | "Again, we can upload signals individually or in batch.\n", 1109 | "\n", 1110 | "You must pass at least a name, queries (list of queries you'd like the signal to apply to) and subscribers. For each subscriber, you have to pass both an emailAddress and notificationThreshold. The notificationThreshold will be a number 1, 2 or 3 - where 1 means send all notifications and 3 means send only high priority signals.\n", 1111 | "\n", 1112 | "Optionally, you can also pass in categories or tags to filter by. As before, you can filter by an entire category with the keyword parentCategory or just a subcategory (or list of subcategories) with the keyword category. An example of how to pass in each filter is shown below." 1113 | ] 1114 | }, 1115 | { 1116 | "cell_type": "code", 1117 | "execution_count": null, 1118 | "metadata": {}, 1119 | "outputs": [], 1120 | "source": [ 1121 | "signals.upload(name= \"New Test\",\n", 1122 | " queries= [\"ice cream cake\"],\n", 1123 | " parentCategory = [\"Colors\"],\n", 1124 | " subscribers= [{\"emailAddress\": \"test12345@brandwatch.com\", \"notificationThreshold\": 1}])\n", 1125 | "\n", 1126 | "signals.upload_all([{\"name\": \"Signal Me\",\n", 1127 | " \"queries\": [\"ice cream cake\"],\n", 1128 | " \"category\": {\"Colors\": [\"Blue\", \"Yellow\"]},\n", 1129 | " \"subscribers\": [{\"emailAddress\": \"testaddress123@brandwatch.com\", \"notificationThreshold\": 3}]},\n", 1130 | " {\"name\": \"Signal Test\",\n", 1131 | " \"queries\": [\"ice cream cake\"],\n", 1132 | " \"tag\": [\"Tastes Good\"],\n", 1133 | " \"subscribers\": [{\"emailAddress\": \"exampleemail@brandwatch.com\", \"notificationThreshold\": 2}]}])" 1134 | ] 1135 | }, 1136 | { 1137 | "cell_type": "code", 1138 | "execution_count": null, 1139 | "metadata": {}, 1140 | "outputs": [], 1141 | "source": [ 1142 | "signals.names" 1143 | ] 1144 | }, 1145 | { 1146 | "cell_type": "markdown", 1147 | "metadata": {}, 1148 | "source": [ 1149 | "Signals can be deleted individually or in bulk." 1150 | ] 1151 | }, 1152 | { 1153 | "cell_type": "code", 1154 | "execution_count": null, 1155 | "metadata": {}, 1156 | "outputs": [], 1157 | "source": [ 1158 | "signals.delete(\"New Test\")\n", 1159 | "signals.delete_all([\"Signal Me\", \"Signal Test\"])" 1160 | ] 1161 | }, 1162 | { 1163 | "cell_type": "code", 1164 | "execution_count": null, 1165 | "metadata": {}, 1166 | "outputs": [], 1167 | "source": [ 1168 | "signals.names" 1169 | ] 1170 | }, 1171 | { 1172 | "cell_type": "markdown", 1173 | "metadata": {}, 1174 | "source": [ 1175 | "## Patching Mentions" 1176 | ] 1177 | }, 1178 | { 1179 | "cell_type": "markdown", 1180 | "metadata": {}, 1181 | "source": [ 1182 | "To patch the metadata on mentions, whether those mentions come from queries or from groups, you must first instantiate a BWMentions object and pass in your project as a parameter. " 1183 | ] 1184 | }, 1185 | { 1186 | "cell_type": "code", 1187 | "execution_count": null, 1188 | "metadata": {}, 1189 | "outputs": [], 1190 | "source": [ 1191 | "mentions = BWMentions(project)" 1192 | ] 1193 | }, 1194 | { 1195 | "cell_type": "code", 1196 | "execution_count": null, 1197 | "metadata": {}, 1198 | "outputs": [], 1199 | "source": [ 1200 | "filtered = queries.get_mentions(name = \"ice cream cake\", \n", 1201 | " startDate = start, \n", 1202 | " endDate = today,\n", 1203 | " parentCategory = [\"Colors\", \"Days\"],\n", 1204 | " category = {\"Colors\": [\"Blue\", \"Yellow\"], \n", 1205 | " \"Days\": [\"Monday\"]}, \n", 1206 | " tag = [\"Tastes Good\"])" 1207 | ] 1208 | }, 1209 | { 1210 | "cell_type": "markdown", 1211 | "metadata": {}, 1212 | "source": [ 1213 | "if you don't want to upload your tags and categories ahead of time, you don't have to! BWMentions will do that for you, but if there are a lot of differnet tags/categories, it's definitely more efficient to upload them in bulk ahead of time" 1214 | ] 1215 | }, 1216 | { 1217 | "cell_type": "markdown", 1218 | "metadata": {}, 1219 | "source": [ 1220 | "For this example, i'm arbitrarily patching a few of the mentions, rather than all of them" 1221 | ] 1222 | }, 1223 | { 1224 | "cell_type": "code", 1225 | "execution_count": null, 1226 | "metadata": {}, 1227 | "outputs": [], 1228 | "source": [ 1229 | "mentions.patch_mentions(filtered[0:10], action = \"addTag\", setting = [\"cold\"])" 1230 | ] 1231 | }, 1232 | { 1233 | "cell_type": "code", 1234 | "execution_count": null, 1235 | "metadata": {}, 1236 | "outputs": [], 1237 | "source": [ 1238 | "mentions.patch_mentions(filtered[5:12], action = \"starred\", setting = True)" 1239 | ] 1240 | }, 1241 | { 1242 | "cell_type": "code", 1243 | "execution_count": null, 1244 | "metadata": {}, 1245 | "outputs": [], 1246 | "source": [ 1247 | "mentions.patch_mentions(filtered[6:8], action = \"addCategories\", setting = {\"color\":[\"green\", \"blue\"]})" 1248 | ] 1249 | }, 1250 | { 1251 | "cell_type": "code", 1252 | "execution_count": null, 1253 | "metadata": { 1254 | "collapsed": true 1255 | }, 1256 | "outputs": [], 1257 | "source": [] 1258 | } 1259 | ], 1260 | "metadata": { 1261 | "kernelspec": { 1262 | "display_name": "Python 3", 1263 | "language": "python", 1264 | "name": "python3" 1265 | }, 1266 | "language_info": { 1267 | "codemirror_mode": { 1268 | "name": "ipython", 1269 | "version": 3 1270 | }, 1271 | "file_extension": ".py", 1272 | "mimetype": "text/x-python", 1273 | "name": "python", 1274 | "nbconvert_exporter": "python", 1275 | "pygments_lexer": "ipython3", 1276 | "version": "3.6.5" 1277 | } 1278 | }, 1279 | "nbformat": 4, 1280 | "nbformat_minor": 2 1281 | } 1282 | -------------------------------------------------------------------------------- /src/bwapi/bwresources.py: -------------------------------------------------------------------------------- 1 | """ 2 | bwresources contains the BWMentions, BWQueries, BWGroups, BWRules, BWTags, BWCategories, BWSiteLists, BWAuthorLists, BWLocationLists, and BWSignals classes. 3 | """ 4 | 5 | import json 6 | from . import filters 7 | from . import bwdata 8 | import logging 9 | 10 | 11 | logger = logging.getLogger("bwapi") 12 | 13 | 14 | class AmbiguityError(ValueError): 15 | """Simple class to make errors when handling resource IDs more clear""" 16 | 17 | pass 18 | 19 | 20 | class BWResource: 21 | """ 22 | This class is a superclass for brandwatch resources (queries, groups, mentions, tags, sitelists, authorlists, locationlists and signals). 23 | 24 | Attributes: 25 | project: Brandwatch project. This is a BWProject object. 26 | names: Query names, organized in a dictionary of the form {query1id: query1name, query2id: query2name, ...} 27 | """ 28 | 29 | def __init__(self, bwproject): 30 | """ 31 | Creates a BWResource object. 32 | 33 | Args: 34 | bwproject: Brandwatch project. This is a BWProject object. 35 | """ 36 | self.project = bwproject 37 | self.names = {} 38 | self.reload() 39 | 40 | def reload(self): 41 | """ 42 | Refreshes names and ids. 43 | 44 | This function is used internally after editing any resource (e.g. uploading) so that our local copy of the id information matches the system's. 45 | The only potential danger is that someone else is editing a resource at the same time you are - in which case your local copy could differ from the system's. 46 | If you fear this has happened, you can call reload() directly. 47 | 48 | Raises: 49 | KeyError: If there was an error with the request for resource information. 50 | """ 51 | response = self.project.get(endpoint=self.general_endpoint) 52 | 53 | if "results" not in response: 54 | raise KeyError("Could not retrieve" + self.resource_type, response) 55 | 56 | self.names = { 57 | resource["id"]: resource["name"] for resource in response["results"] 58 | } 59 | 60 | def get_resource_id(self, resource=None): 61 | """Takes in a resource ID or name and returns the resource ID. Raises an error if an ambiguous name is provided (e.g. if user calls this function with 'Query1' and there is actually a query and a logo query with that name) 62 | """ 63 | if not resource: 64 | return ( 65 | "" 66 | ) # return empty string rather than none to avoid stringified "None" becoming part of the url of an API call 67 | if isinstance(resource, int): 68 | if resource not in self.names.keys(): 69 | raise KeyError( 70 | "Could not find the resource ID {} in the project".format(resource) 71 | ) 72 | resource_id = resource 73 | elif isinstance(resource, str): 74 | entries = [ 75 | resource_id 76 | for resource_id, name in self.names.items() 77 | if name == resource 78 | ] 79 | if len(entries) > 1: 80 | raise AmbiguityError( 81 | "The resource name {} is ambiguous: {}".format(resource, entries) 82 | ) 83 | if entries: 84 | return entries[0] 85 | else: 86 | try: 87 | resource_id = int(resource) 88 | except ValueError: 89 | raise KeyError( 90 | "Could not find the resource name {} in the project".format( 91 | resource 92 | ) 93 | ) 94 | if resource_id not in self.names.keys(): 95 | raise KeyError( 96 | "Could not find the resource ID {} in the project".format(resource) 97 | ) 98 | if resource_id: 99 | return resource_id 100 | 101 | def check_resource_exists(self, resource): 102 | try: 103 | self.get_resource_id(resource) 104 | return True 105 | # Check the type of error 106 | # Key errors relate to the resource not being present, if KeyError return False, because the resource doesn't exist 107 | # If there's a ValueError, we want that still to be raised, because it means the resource name is ambiguous, and we want to raise that 108 | except AmbiguityError: 109 | raise 110 | except KeyError: 111 | return False 112 | 113 | def get(self, name=None): 114 | """ 115 | If you specify an ID, this function will retrieve all information for that resource as it is stored in Brandwatch. 116 | If you specify a name, this will be mapped to the appropriate ID. An error will be raised if there are two IDs with the name specified. 117 | If you do not pass anything in with the `name` argument, this function will retrieve all information for all resources of that type as they are stored in Brandwatch. 118 | 119 | Args: 120 | name: ID or name of the resource that you'd like to retrieve - Optional. If you do not specify an ID, all resources of that type will be retrieved. 121 | 122 | Raises: 123 | KeyError: If you specify a resource ID and the resource does not exist. 124 | 125 | Returns: 126 | All information for the specified resource, or a list of information on every resource of that type in the account. 127 | """ 128 | id_num = self.get_resource_id(resource=name) 129 | return self.project.get(endpoint=self.specific_endpoint + "/" + str(id_num)) 130 | 131 | def upload(self, create_only=False, modify_only=False, **kwargs): 132 | """ 133 | Uploads a resource. 134 | 135 | Args: 136 | create_only: If True and the resource already exists, no action will be triggered - Optional. Defaults to False. 137 | modify_only: If True and the resource does not exist, no action will be triggered - Optional. Defaults to False. 138 | kwargs: Keyword arguments for resource information. Error handling is handeled in the child classes. 139 | 140 | Returns: 141 | The uploaded resource information in a dictionary of the form {resource1name: resource1id} 142 | 143 | """ 144 | return self.upload_all([kwargs], create_only, modify_only) 145 | 146 | def upload_all(self, data_list, create_only=False, modify_only=False): 147 | """ 148 | Uploads a list of resources. 149 | 150 | Args: 151 | data_list: List of data for each resource. Error handling is handeled in the child classes. 152 | create_only: If True and the query already exists, no action will be triggered - Optional. Defaults to False. 153 | modify_only: If True and the query does not exist, no action will be triggered - Optional. Defaults to False. 154 | 155 | Returns: 156 | The uploaded resource information in a dictionary of the form {resource1name: resource1id, resource2name: resource2id, ...} 157 | """ 158 | resources = {} 159 | 160 | for data in data_list: 161 | # eventually make _fill_data() a BWResource func 162 | filled_data = self._fill_data(data) 163 | name = data["name"] 164 | 165 | if self.check_resource_exists(name) and not create_only: 166 | resource_id = self.get_resource_id(name) 167 | response = self.project.put( 168 | endpoint=self.specific_endpoint + "/" + str(resource_id), 169 | data=filled_data, 170 | ) 171 | elif ( 172 | not self.check_resource_exists(name) and not modify_only 173 | ): # if resource does not exist 174 | response = self.project.post( 175 | endpoint=self.specific_endpoint, data=filled_data 176 | ) 177 | else: 178 | continue 179 | 180 | logger.info("Uploading {} {}".format(self.resource_type, response["name"])) 181 | resources[response["name"]] = response["id"] 182 | 183 | self.reload() 184 | return resources 185 | 186 | def rename(self, name, new_name): 187 | """ 188 | Renames an existing resource. 189 | 190 | Args: 191 | name: Name of existing resource. 192 | new_name: New name for the resource. 193 | 194 | Raises: 195 | KeyError: If the resource does not exist. 196 | """ 197 | if not self.check_resource_exists(name): # if the resource does not exist 198 | raise KeyError( 199 | "Cannot rename a " + self.resource_type + " which does not exist", name 200 | ) 201 | else: 202 | info = self.get( 203 | name=name 204 | ) # will raise error if ambiguous name provided, so we should be okay to provide a name from this point forward (it won't be ambiguous if we get past this stage) 205 | info.pop("name") 206 | self.upload(name=name, new_name=new_name, **info) 207 | 208 | def delete(self, name): 209 | """ 210 | Deletes a resource. 211 | 212 | Args: 213 | name: Name of the resource that you'd like to delete. 214 | """ 215 | self.delete_all([name]) 216 | 217 | def delete_all(self, names): 218 | """ 219 | Deletes a list of resources. 220 | 221 | Args: 222 | names: A list of the names of the queries that you'd like to delete. 223 | """ 224 | resource_ids = [self.get_resource_id(x) for x in names] 225 | 226 | for resource_id in resource_ids: 227 | if resource_id in self.names.keys(): 228 | self.project.delete( 229 | endpoint=self.specific_endpoint + "/" + str(resource_id) 230 | ) 231 | logger.info( 232 | "{} {} deleted".format(self.resource_type, self.names[resource_id]) 233 | ) 234 | 235 | self.reload() 236 | 237 | def _fill_data(): 238 | raise NotImplementedError 239 | 240 | 241 | class BWQueries(BWResource, bwdata.BWData): 242 | """ 243 | This class provides an interface for query level operations within a prescribed project (e.g. uploading, downloading, renaming, downloading a list of mentions). 244 | 245 | Attributes: 246 | tags: All tags in the project - handeled at the class level to prevent repetitive API calls. This is a BWTags object. 247 | categories: All categories in the project - handeled at the class level to prevent repetitive API calls. This is a BWCategories object. 248 | """ 249 | 250 | general_endpoint = "queries" 251 | specific_endpoint = "queries" 252 | resource_type = "queries" 253 | resource_id_name = "queryId" 254 | 255 | def __init__(self, bwproject): 256 | """ 257 | Creates a BWQueries object. 258 | 259 | Args: 260 | bwproject: Brandwatch project. This is a BWProject object. 261 | """ 262 | super(BWQueries, self).__init__(bwproject) 263 | self.tags = BWTags(self.project) 264 | self.categories = BWCategories(self.project) 265 | 266 | def upload(self, create_only=False, modify_only=False, backfill_date="", **kwargs): 267 | """ 268 | Uploads a query. 269 | 270 | Args: 271 | create_only: If True and the query already exists, no action will be triggered - Optional. Defaults to False. 272 | modify_only: If True and the query does not exist, no action will be triggered - Optional. Defaults to False. 273 | backfill_date: Date which you'd like to backfill the query too (yyyy-mm-dd) - Optional. 274 | kwargs: You must pass in name (string) and includedTerms (string). You can also optionally pass in languages, type, industry and samplePercent. 275 | 276 | Returns: 277 | The uploaded query information in a dictionary of the form {query1name: query1id} 278 | """ 279 | return self.upload_all([kwargs], create_only, modify_only, backfill_date) 280 | 281 | def upload_all( 282 | self, data_list, create_only=False, modify_only=False, backfill_date="" 283 | ): 284 | """ 285 | Uploads multiple queries. 286 | 287 | Args: 288 | data_list: You must pass in name (string) and includedTerms (string). You can also optionally pass in languages, type, industry and samplePercent. 289 | create_only: If True and the query already exists, no action will be triggered - Optional. Defaults to False. 290 | modify_only: If True and the query does not exist, no action will be triggered - Optional. Defaults to False. 291 | backfill_date: Date which you'd like to backfill the query too (yyyy-mm-dd) - Optional. 292 | 293 | Raises: 294 | KeyError: If you do not pass name and includedTerms for each query in the data_list. 295 | 296 | Returns: 297 | The uploaded query information in a dictionary of the form {query1name: query1id, query2name: query2id, ...} 298 | """ 299 | 300 | queries = super(BWQueries, self).upload_all( 301 | data_list, create_only=False, modify_only=False 302 | ) 303 | 304 | # backfill if passed in with individual query data 305 | for query in data_list: 306 | if "backfill_date" in query: 307 | self.backfill(queries[query["name"]], query["backfill_date"]) 308 | 309 | # backfill if passed in for all 310 | if backfill_date != "": 311 | for query in queries: 312 | self.backfill(queries[query], backfill_date) 313 | 314 | return queries 315 | 316 | def rename(self, name, new_name): 317 | """ 318 | Renames an existing resource. 319 | 320 | Args: 321 | name: Name of existing resource. 322 | new_name: New name for the resource. 323 | 324 | Raises: 325 | KeyError: If the resource does not exist. 326 | """ 327 | if not self.check_resource_exists(name): 328 | raise KeyError( 329 | "Cannot rename a " + self.resource_type + " which does not exist", name 330 | ) 331 | else: 332 | info = self.get(name=name) 333 | info.pop("name") 334 | if info["type"] == "search string": 335 | self.upload(name=name, new_name=new_name, **info) 336 | else: 337 | raise KeyError( 338 | "We cannot support automated renaming of channels at this time." 339 | ) 340 | 341 | def backfill(self, query_id, backfill_date): 342 | """ 343 | Backfills a query to a specified date. 344 | 345 | Args: 346 | query_id: Query id 347 | backfill_date: Date that you'd like to backfill the query to (yyy-mm-dd). 348 | 349 | Returns: 350 | Server's response to the post request. 351 | """ 352 | backfill_endpoint = "queries/" + str(query_id) + "/backfill" 353 | backfill_data = {"minDate": backfill_date, "queryId": query_id} 354 | return self.project.post( 355 | endpoint=backfill_endpoint, data=json.dumps(backfill_data) 356 | ) 357 | 358 | def get_mention(self, **kwargs): 359 | """ 360 | Retrieves a single mention by url or resource id. 361 | This is ONLY a valid function for queries (not groups), which is why it isn't split out into bwdata. 362 | Note: Clients do not have access to full Twitter mentions through the API because of our data agreement with Twitter. 363 | 364 | Args: 365 | kwargs: You must pass in name (query name), and either url or resourceId. 366 | 367 | Raises: 368 | KeyError: If the mentions call fails. 369 | 370 | Returns: 371 | A single mention. 372 | """ 373 | params = self._fill_mention_params(kwargs) 374 | resource_id = self.get_resource_id(kwargs["name"]) 375 | mention = self.project.get( 376 | endpoint="query/" + str(resource_id) + "/mentionfind", params=params 377 | ) 378 | 379 | if "errors" in mention: 380 | raise KeyError("Mentions GET request failed", mention) 381 | return mention["mention"] 382 | 383 | def _name_to_id(self, attribute, setting): 384 | if isinstance(setting, int): 385 | return setting 386 | 387 | elif isinstance(setting, list): 388 | try: 389 | return [int(i) for i in setting] 390 | except ValueError: 391 | pass 392 | 393 | if attribute in ["category", "xcategory"]: 394 | # setting is a dictionary with one key-value pair, so this loop iterates only once 395 | # but is necessary to extract the values in the dictionary 396 | ids = [] 397 | for category in setting: 398 | parent = category 399 | children = setting[category] 400 | for child in children: 401 | ids.append(self.categories.ids[parent]["children"][child]) 402 | return ids 403 | 404 | elif attribute in [ 405 | "parentCategory", 406 | "xparentCategory", 407 | "parentCategories", 408 | "categories", 409 | ]: 410 | # plural included for get_charts syntax 411 | # note: parentCategories and categories params will be ignored for everything but chart calls 412 | if not isinstance(setting, list): 413 | setting = [setting] 414 | ids = [] 415 | for s in setting: 416 | ids.append(self.categories.ids[s]["id"]) 417 | return ids 418 | 419 | elif attribute in ["tag", "xtag", "tags"]: 420 | # plural included for get_charts syntax 421 | if not isinstance(setting, list): 422 | setting = [setting] 423 | ids = [] 424 | for s in setting: 425 | ids.append(self.tags.get_resource_id(s)) 426 | return ids 427 | 428 | elif attribute in ["authorGroup", "xauthorGroup"]: 429 | authorlists = BWAuthorLists(self.project) 430 | if not isinstance(setting, list): 431 | setting = [setting] 432 | ids = [] 433 | for s in setting: 434 | ids.append(authorlists.get(s)["id"]) 435 | return ids 436 | 437 | elif attribute in [ 438 | "locationGroup", 439 | "xlocationGroup", 440 | "authorLocationGroup", 441 | "xauthorLocationGroup", 442 | ]: 443 | locationlists = BWLocationLists(self.project) 444 | if not isinstance(setting, list): 445 | setting = [setting] 446 | ids = [] 447 | for s in setting: 448 | ids.append(locationlists.get(s)["id"]) 449 | return ids 450 | 451 | elif attribute in ["siteGroup", "xsiteGroup"]: 452 | sitelists = BWSiteLists(self.project) 453 | if not isinstance(setting, list): 454 | setting = [setting] 455 | ids = [] 456 | for s in setting: 457 | ids.append(sitelists.get(s)["id"]) 458 | return ids 459 | 460 | else: 461 | return setting 462 | 463 | def _fill_data(self, data): 464 | filled = {} 465 | 466 | if ("name" not in data) or ("includedTerms" not in data): 467 | raise KeyError("Need name and includedTerms to post query", data) 468 | if self.check_resource_exists( 469 | data["name"] 470 | ): # if resource exists, create value for filled['id'] 471 | filled["id"] = self.get_resource_id(data["name"]) 472 | if "new_name" in data: 473 | filled["name"] = data["new_name"] 474 | else: # if resource doesn't exist, add name to filled dictionary 475 | filled["name"] = data["name"] 476 | 477 | filled["includedTerms"] = data["includedTerms"] 478 | filled["languages"] = data["languages"] if "languages" in data else ["en"] 479 | filled["type"] = data["type"] if "type" in data else "search string" 480 | filled["industry"] = ( 481 | data["industry"] if "industry" in data else "general-(recommended)" 482 | ) 483 | filled["samplePercent"] = ( 484 | data["samplePercent"] if "samplePercent" in data else 100 485 | ) 486 | filled["languageAgnostic"] = ( 487 | data["languageAgnostic"] if "languageAgnostic" in data else False 488 | ) 489 | 490 | # validating the query search - comment this out to skip validation 491 | self.project.validate_query_search( 492 | query=filled["includedTerms"], language=filled["languages"] 493 | ) 494 | 495 | return json.dumps(filled) 496 | 497 | def _fill_mention_params(self, data): 498 | if "name" not in data: 499 | raise KeyError("Must specify query or group name", data) 500 | elif not self.check_resource_exists(data["name"]): # if resource does not exist 501 | raise KeyError("Could not find " + self.resource_type + " " + data["name"]) 502 | if ("url" not in data) and ("resourceId" not in data): 503 | raise KeyError("Must provide either a url or a resourceId", data) 504 | 505 | filled = {} 506 | if "url" in data: 507 | filled["url"] = data["url"] 508 | else: 509 | filled["resourceId"] = data["resourceId"] 510 | 511 | return filled 512 | 513 | 514 | class BWGroups(BWResource, bwdata.BWData): 515 | """ 516 | This class provides an interface for group level operations within a prescribed project. 517 | 518 | Attributes: 519 | queries: All queries in the project - handeled at the class level to prevent repetitive API calls. This is a BWQueries object. 520 | tags: All tags in the project - handeled at the class level to prevent repetitive API calls. This is a BWTags object. 521 | categories: All categories in the project - handeled at the class level to prevent repetitive API calls. This is a BWCategories object. 522 | """ 523 | 524 | general_endpoint = "querygroups" 525 | specific_endpoint = "querygroups" 526 | resource_type = "groups" 527 | resource_id_name = "queryGroupId" 528 | 529 | def __init__(self, bwproject): 530 | """ 531 | Creates a BWGroups object. 532 | 533 | Args: 534 | bwproject: Brandwatch project. This is a BWProject object. 535 | """ 536 | 537 | super(BWGroups, self).__init__(bwproject) 538 | self.queries = BWQueries(self.project) 539 | self.tags = self.queries.tags 540 | self.categories = self.queries.categories 541 | 542 | def rename(self, name, new_name): 543 | """ 544 | Renames an existing resource. 545 | 546 | Args: 547 | name: Name of existing resource. 548 | new_name: New name for the resource. 549 | 550 | Raises: 551 | KeyError: If the resource does not exist. 552 | """ 553 | if not self.check_resource_exists(name): 554 | raise KeyError( 555 | "Cannot rename a " + self.resource_type + " which does not exist", name 556 | ) 557 | else: 558 | info = self.get(name=name) 559 | queries = [x["name"] for x in info["queries"]] 560 | self.upload(name=name, new_name=new_name, queries=queries) 561 | 562 | def upload_queries_as_group( 563 | self, 564 | group_name, 565 | query_data_list, 566 | create_only=False, 567 | modify_only=False, 568 | backfill_date="", 569 | **kwargs 570 | ): 571 | """ 572 | Uploads a list of queries and saves them as a group. 573 | 574 | Args: 575 | group_name: Name of the group. 576 | query_data_list: List of dictionaries, where each dictionary includes the information for one query in the following format {name: queryname, includedTerms: searchstring} 577 | create_only: If True and the group already exists, no action will be triggered - Optional. Defaults to False. 578 | modify_only: If True and the group does not exist, no action will be triggered - Optional. Defaults to False. 579 | backfill_date: Date which you'd like to backfill the queries too (yyyy-mm-dd) - Optional. 580 | kwargs: You can pass in shared, sharedProjectIds and users - Optional. 581 | 582 | Returns: 583 | The uploaded group information in a dictionary of the form {groupname: groupid} 584 | """ 585 | kwargs["queries"] = self.queries.upload_all( 586 | query_data_list, create_only, modify_only, backfill_date 587 | ) 588 | kwargs["name"] = group_name 589 | return self.upload(create_only, modify_only, **kwargs) 590 | 591 | def deep_delete(self, name): 592 | """ 593 | Deletes a group and all of the queries in the group. 594 | 595 | Args: 596 | name: Name of the group that you'd like to delete. 597 | """ 598 | # No need to delete the group itself, since a group will be deleted automatically when empty 599 | BWQueries(self.project).delete_all(self.get_group_queries(name)) 600 | logger.info("Group {} deleted".format(name)) 601 | 602 | def get_group_queries(self, name): 603 | """ 604 | Retrieves information about the queries in the group. 605 | 606 | Args: 607 | name: Name of the group that you'd like to retrieve. 608 | 609 | Returns: 610 | A dictionary of the form {query1name: query1id, query2name:query2id, ...}. 611 | """ 612 | return {q["name"]: q["id"] for q in self.get(name)["queries"]} 613 | 614 | def _name_to_id(self, attribute, setting): 615 | if isinstance(setting, int): 616 | return setting 617 | 618 | elif isinstance(setting, list): 619 | try: 620 | return [int(i) for i in setting] 621 | except ValueError: 622 | pass 623 | 624 | if attribute in ["category", "xcategory"]: 625 | # setting is a dictionary with one key-value pair, so this loop iterates only once 626 | # but is necessary to extract the values in the dictionary 627 | ids = [] 628 | for category in setting: 629 | parent = category 630 | children = setting[category] 631 | for child in children: 632 | ids.append(self.categories.ids[parent]["children"][child]) 633 | return ids 634 | 635 | elif attribute in [ 636 | "parentCategory", 637 | "xparentCategory", 638 | "parentCategories", 639 | "categories", 640 | ]: 641 | # plural included for get_charts syntax 642 | # note: parentCategories and categories params will be ignored for everything but chart calls 643 | if not isinstance(setting, list): 644 | setting = [setting] 645 | ids = [] 646 | for s in setting: 647 | ids.append(self.categories.ids[s]["id"]) 648 | return ids 649 | 650 | elif attribute in ["tag", "xtag", "tags"]: 651 | # plural included for get_charts syntax 652 | if not isinstance(setting, list): 653 | setting = [setting] 654 | ids = [] 655 | for s in setting: 656 | ids.append(self.tags.get_resource_id(s)) 657 | return ids 658 | 659 | elif attribute in ["authorGroup", "xauthorGroup"]: 660 | authorlists = BWAuthorLists(self.project) 661 | if not isinstance(setting, list): 662 | setting = [setting] 663 | ids = [] 664 | for s in setting: 665 | ids.append(authorlists.get(s)["id"]) 666 | return ids 667 | 668 | elif attribute in [ 669 | "locationGroup", 670 | "xlocationGroup", 671 | "authorLocationGroup", 672 | "xauthorLocationGroup", 673 | ]: 674 | locationlists = BWLocationLists(self.project) 675 | if not isinstance(setting, list): 676 | setting = [setting] 677 | ids = [] 678 | for s in setting: 679 | ids.append(locationlists.get(s)["id"]) 680 | return ids 681 | 682 | elif attribute in ["siteGroup", "xsiteGroup"]: 683 | sitelists = BWSiteLists(self.project) 684 | if not isinstance(setting, list): 685 | setting = [setting] 686 | ids = [] 687 | for s in setting: 688 | ids.append(sitelists.get(s)["id"]) 689 | return ids 690 | 691 | else: 692 | return setting 693 | 694 | def _fill_data(self, data): 695 | filled = {} 696 | if ("name" not in data) or ("queries" not in data): 697 | raise KeyError("Need name and queries to upload group", data) 698 | if self.check_resource_exists( 699 | data["name"] 700 | ): # if resource exists, create value for filled['id'] 701 | filled["id"] = self.get_resource_id(data["name"]) 702 | 703 | if "new_name" in data: 704 | filled["name"] = data["new_name"] 705 | else: 706 | filled["name"] = data["name"] 707 | 708 | queries = data["queries"] 709 | query_ids = [self.queries.get_resource_id(resource=x) for x in queries] 710 | 711 | # now we have a reliable list of ids, we can turn this into a list of dictionaries in the form [{'name': 'MyQuery', 'id': 1111}] 712 | filled["queries"] = [ 713 | {"name": self.queries.names[resource_id], "id": resource_id} 714 | for resource_id in query_ids 715 | ] 716 | filled["shared"] = data["shared"] if "shared" in data else "public" 717 | filled["sharedProjectIds"] = ( 718 | data["sharedProjectIds"] 719 | if "sharedProjectIds" in data 720 | else [self.project.project_id] 721 | ) 722 | filled["users"] = ( 723 | data["users"] 724 | if "users" in data 725 | else [{"id": self.project.get_self()["id"]}] 726 | ) 727 | return json.dumps(filled) 728 | 729 | 730 | class BWMentions: 731 | """ 732 | This class handles patching lists of mentions. 733 | For retrieving mentions, see the BWQueries or BWGroups class instead (as you must specify a query or group in order to retrieve mentions, we thought it most sensible to tie that task to the BWQueries and BWGroups classes). 734 | 735 | Attributes: 736 | tags: All tags in the project - handeled at the class level to prevent repetitive API calls. This is a BWTags object. 737 | categories: All categories in the project - handeled at the class level to prevent repetitive API calls. This is a BWCategories object. 738 | """ 739 | 740 | def __init__(self, bwproject): 741 | """ 742 | Creates a BWMentions object. 743 | 744 | Args: 745 | bwproject: Brandwatch project. This is a BWProject object. 746 | """ 747 | self.project = bwproject 748 | self.tags = BWTags(self.project) 749 | self.categories = BWCategories(self.project) 750 | 751 | def patch_mentions(self, mentions, action, setting): 752 | """ 753 | Edits a list of mentions by adding or removing categories, tags, priority, status, or assignment, or changing sentiment, checked or starred status, or location. 754 | This function will also handle uploading categories and tags, if you want to edit mentions by adding categories or tags that do not yet exist in the system. 755 | 756 | Args: 757 | mentions: List of mentions to be edited. 758 | action: Action to be taken when editing the mention. See the list titled mutable in filters.py for the possible actions you can take to edit a mention. 759 | setting: If the action is addTag or removeTag, the setting is a list of string(s) where each string is a tag name. If the action is addCategories or removeCategories, the setting is a dictionary of in the format: {parent:[child1, child2, etc]} for any number of subcatagories (parent subcatagory names are strings). See the dictionary titled mutable_options in filters.py for the accepted values for other actions. 760 | 761 | Raises: 762 | KeyError: If you pass in an invalid action or setting. 763 | KeyError: If there is an error when attempting to edit the mentions. 764 | """ 765 | 766 | # add cats and tags if they don't exist 767 | if action in ["addCategories", "removeCategories"]: 768 | # the following loop is only one iteration 769 | for category in setting: 770 | parent = category 771 | children = setting[category] 772 | 773 | self.categories.upload(name=parent, children=children) 774 | setting = [] 775 | for child in children: 776 | setting.append(self.categories.ids[parent]["children"][child]) 777 | 778 | elif action in ["addTag", "removeTag"]: 779 | for s in setting: 780 | self.tags.upload(name=s, create_only=True) 781 | 782 | filled_data = [] 783 | for mention in mentions: 784 | if action in filters.mutable and self._valid_patch_input(action, setting): 785 | filled_data.append( 786 | self._fill_mention_data( 787 | mention=mention, action=action, setting=setting 788 | ) 789 | ) 790 | else: 791 | raise KeyError("invalid action or setting", action, setting) 792 | response = self.project.patch( 793 | endpoint="data/mentions", data=json.dumps(filled_data) 794 | ) 795 | 796 | if "errors" in response: 797 | raise KeyError("patch failed", response) 798 | 799 | logger.info("{} mentions updated".format(len(response))) 800 | 801 | def _valid_patch_input(self, action, setting): 802 | """ internal use """ 803 | if not isinstance(setting, filters.mutable[action]): 804 | return False 805 | if ( 806 | action in filters.mutable_options 807 | and setting not in filters.mutable_options[action] 808 | ): 809 | return False 810 | else: 811 | return True 812 | 813 | def _fill_mention_data(self, **data): 814 | """ internal use """ 815 | # pass in mention, filter_type, setting 816 | filled = {} 817 | 818 | filled["queryId"] = data["mention"]["queryId"] 819 | filled["resourceId"] = data["mention"]["resourceId"] 820 | 821 | if data["action"] in filters.mutable: 822 | filled[data["action"]] = data["setting"] 823 | else: 824 | raise KeyError("not a mutable field", data["action"]) 825 | 826 | return filled 827 | 828 | 829 | class BWAuthorLists(BWResource): 830 | """ 831 | This class provides an interface for Author List operations within a prescribed project. 832 | """ 833 | 834 | general_endpoint = "group/author/summary" 835 | specific_endpoint = "group/author" 836 | resource_type = "authorlists" 837 | 838 | def add_items(self, name, items): 839 | """ 840 | Adds authors to an existing author list. 841 | 842 | Args: 843 | name: Name of the author list to edit. 844 | items: List of new authors to add. 845 | """ 846 | prev_list = set(self.get(name)["authors"]) 847 | prev_list.update(items) 848 | new_list = list(prev_list) 849 | 850 | self.upload(name=name, authors=new_list) 851 | 852 | def _fill_data(self, data): 853 | filled = {} 854 | 855 | if ("name" not in data) or ("authors" not in data): 856 | raise KeyError("Need name and authors to upload authorlist", data) 857 | if self.check_resource_exists( 858 | data["name"] 859 | ): # if resource exists, create value for filled['id'] 860 | filled["id"] = self.get_resource_id(data["name"]) 861 | 862 | if "new_name" in data: 863 | filled["name"] = data["new_name"] 864 | else: 865 | filled["name"] = data["name"] 866 | 867 | filled["authors"] = data["authors"] 868 | 869 | filled["shared"] = data["shared"] if "shared" in data else "public" 870 | filled["sharedProjectIds"] = ( 871 | data["sharedProjectIds"] 872 | if "sharedProjectIds" in data 873 | else [self.project.project_id] 874 | ) 875 | 876 | filled["userName"] = self.project.username 877 | filled["userId"] = self.project.get_self()["id"] 878 | return json.dumps(filled) 879 | 880 | 881 | class BWSiteLists(BWResource): 882 | """ 883 | This class provides an interface for Site List operations within a prescribed project. 884 | """ 885 | 886 | general_endpoint = "group/site/summary" 887 | specific_endpoint = "group/site" 888 | resource_type = "sitelists" 889 | 890 | def add_items(self, name, items): 891 | """ 892 | Adds sites to an existing site list. 893 | 894 | Args: 895 | name: Name of the site list to edit. 896 | items: List of new sites to add. 897 | """ 898 | prev_list = set(self.get(name)["domains"]) 899 | prev_list.update(items) 900 | new_list = list(prev_list) 901 | 902 | self.upload(name=name, domains=new_list) 903 | 904 | def _fill_data(self, data): 905 | filled = {} 906 | 907 | if ("name" not in data) or ("domains" not in data): 908 | raise KeyError("Need name and domains to upload sitelist", data) 909 | 910 | if self.check_resource_exists( 911 | data["name"] 912 | ): # if resource exists, create value for filled['id'] 913 | filled["id"] = self.get_resource_id(data["name"]) 914 | 915 | if "new_name" in data: 916 | filled["name"] = data["new_name"] 917 | else: 918 | filled["name"] = data["name"] 919 | 920 | filled["domains"] = data["domains"] 921 | 922 | filled["shared"] = data["shared"] if "shared" in data else "public" 923 | filled["sharedProjectIds"] = ( 924 | data["sharedProjectIds"] 925 | if "sharedProjectIds" in data 926 | else [self.project.project_id] 927 | ) 928 | 929 | filled["userName"] = self.project.username 930 | filled["userId"] = self.project.get_self()["id"] 931 | return json.dumps(filled) 932 | 933 | 934 | class BWLocationLists(BWResource): 935 | """ 936 | This class provides an interface for Location List operations within a prescribed project. 937 | """ 938 | 939 | general_endpoint = "group/location/summary" 940 | specific_endpoint = "group/location" 941 | resource_type = "locationlists" 942 | 943 | def add_items(self, name, items): 944 | """ 945 | Adds sites to an existing site list. 946 | 947 | Args: 948 | name: Name of the location list to edit. 949 | items: List of new locations to add. 950 | """ 951 | prev_list = self.get(name)["locations"] 952 | new_list = prev_list 953 | for item in items: 954 | new_list.append(item) 955 | 956 | self.upload(name=name, locations=new_list) 957 | 958 | def _fill_data(self, data): 959 | filled = {} 960 | 961 | if ("name" not in data) or ("locations" not in data): 962 | raise KeyError("Need name and locations to upload locationlist", data) 963 | 964 | if self.check_resource_exists(data["name"]): 965 | filled["id"] = self.get_resource_id(data["name"]) 966 | 967 | if "new_name" in data: 968 | filled["name"] = data["new_name"] 969 | else: 970 | filled["name"] = data["name"] 971 | 972 | filled["locations"] = data["locations"] 973 | 974 | filled["shared"] = data["shared"] if "shared" in data else "public" 975 | filled["sharedProjectIds"] = ( 976 | data["sharedProjectIds"] 977 | if "sharedProjectIds" in data 978 | else [self.project.project_id] 979 | ) 980 | 981 | filled["userName"] = self.project.username 982 | filled["userId"] = self.project.get_self()["id"] 983 | return json.dumps(filled) 984 | 985 | 986 | class BWTags(BWResource): 987 | """ 988 | This class provides an interface for Tag operations within a prescribed project. 989 | """ 990 | 991 | general_endpoint = "tags" 992 | specific_endpoint = "tags" 993 | resource_type = "tags" 994 | 995 | def clear_all_in_project(self): 996 | """ WARNING: This is the nuclear option. Do not use lightly. It deletes ALL tags in the project. """ 997 | self.delete_all(list(self.names)) 998 | 999 | def _fill_data(self, data): 1000 | filled = {} 1001 | 1002 | if "name" not in data: 1003 | raise KeyError("Need name to upload " + self.parameter, data) 1004 | 1005 | if "new_name" in data: 1006 | filled["id"] = self.get_resource_id(data["name"]) 1007 | filled["name"] = data["new_name"] 1008 | else: 1009 | filled["name"] = data["name"] 1010 | 1011 | return json.dumps(filled) 1012 | 1013 | 1014 | class BWCategories: 1015 | """ 1016 | This class provides an interface for Category operations within a prescribed project. 1017 | 1018 | This class is odd because of its id structure, and for this reason it does not inherit from BWResource. 1019 | Instead of just storing parent category id, we need to store parent categories and their ids, as well as their children and their children ids - hence the nested dictionary. 1020 | 1021 | Attributes: 1022 | project: Brandwatch project. This is a BWProject object. 1023 | ids: Category information, organized in a dictionary of the form {category1name: {id: category1id, multiple: True/False, children: {child1name: child1id, ...}}, ...}. Where multiple is a boolean flag to indicate whether or not to make subcategories mutually exclusive. 1024 | """ 1025 | 1026 | def __init__(self, bwproject): 1027 | """ 1028 | Creates a BWCategories object. 1029 | 1030 | Args: 1031 | bwproject: Brandwatch project. This is a BWProject object. 1032 | """ 1033 | self.project = bwproject 1034 | self.ids = {} 1035 | self.reload() 1036 | 1037 | def reload(self): 1038 | """ 1039 | Refreshes category.ids. 1040 | 1041 | This function is used internally after editing any categories (e.g. uploading) so that our local copy of the id information matches the system's. 1042 | The only potential danger is that someone else is editing categories at the same time you are - in which case your local copy could differ from the system's. 1043 | If you fear this has happened, you can call reload() directly. 1044 | 1045 | Raises: 1046 | KeyError: If there was an error with the request for category information. 1047 | """ 1048 | response = self.project.get(endpoint="categories") 1049 | 1050 | if "results" not in response: 1051 | raise KeyError("Could not retrieve categories", response) 1052 | 1053 | else: 1054 | self.ids = {} 1055 | for cat in response["results"]: 1056 | children = {} 1057 | for child in cat["children"]: 1058 | children[child["name"]] = child["id"] 1059 | self.ids[cat["name"]] = { 1060 | "id": cat["id"], 1061 | "multiple": cat["multiple"], 1062 | "children": children, 1063 | } 1064 | 1065 | def upload( 1066 | self, create_only=False, modify_only=False, overwrite_children=False, **kwargs 1067 | ): 1068 | """ 1069 | Uploads a category. 1070 | 1071 | You can upload a new category, add subcategories to an existing category, overwrite the subcategories of an existing category, or change the name of an existing category with this function. 1072 | 1073 | Args: 1074 | create_only: If True and the category already exists, no action will be triggered - Optional. Defaults to False. 1075 | modify_only: If True and the category does not exist, no action will be triggered - Optional. Defaults to False. 1076 | overwrite_children: Boolen flag that indicates if existing subcategories should be appended or overwriten - Optional. Defaults to False (appending new subcategories). 1077 | kwargs: You must pass in name (parent category name) and children (list of subcategories). You can optionally pass in multiple (boolean - indicates if subcategories are mutually exclusive) and/or new_name (string) if you would like to change the name of an existing category. 1078 | 1079 | Returns: 1080 | A dictionary of the form {id: categoryid, multiple: True/False, children: {child1name: child1id, ...}} 1081 | """ 1082 | return self.upload_all([kwargs], create_only, modify_only, overwrite_children) 1083 | 1084 | def upload_all( 1085 | self, data_list, create_only=False, modify_only=False, overwrite_children=False 1086 | ): 1087 | """ 1088 | Uploads a list of categories. 1089 | 1090 | You can upload a new categories, add subcategories to existing categories, overwrite the subcategories of existing categories, or change the name of an existing categories with this function. 1091 | 1092 | Args: 1093 | data_list: List of dictionaries where each dictionary contains at least name (parent category name) and children (list of subcategories), and optionally multiple (boolean - indicates if subcategories are mutually exclusive) and/or new_name (string) if you would like to change the name of an existing category. 1094 | create_only: If True and the category already exists, no action will be triggered - Optional. Defaults to False. 1095 | modify_only: If True and the category does not exist, no action will be triggered - Optional. Defaults to False. 1096 | overwrite_children: Boolen flag that indicates if existing subcategories should be appended or overwriten - Optional. Defaults to False (appending new subcategories). 1097 | 1098 | Raises: 1099 | KeyError: If you do not pass in a category name. 1100 | KeyError: If you do not pass in a list of children. (You cannot upload a parent category that has no subcategories). 1101 | 1102 | Returns: 1103 | A dictionary for each of the uploaded queries in the form {id: categoryid, multiple: True/False, children: {child1name: child1id, ...}} 1104 | """ 1105 | for data in data_list: 1106 | if "name" not in data: 1107 | raise KeyError("Need name to upload " + self.parameter, data) 1108 | elif "children" not in data: 1109 | raise KeyError("Need children to upload categories", data) 1110 | else: 1111 | name = data["name"] 1112 | 1113 | if name in self.ids and not create_only: 1114 | 1115 | new_children = [] 1116 | existing_children = list(self.ids[name]["children"]) 1117 | for child in data["children"]: 1118 | if child not in existing_children: 1119 | new_children.append(child) 1120 | 1121 | if new_children or overwrite_children: 1122 | if not overwrite_children: 1123 | # add the new children to the existing children 1124 | for child in self.ids[name]["children"]: 1125 | # don't append or else the data object will be affected outside of this function 1126 | data["children"] = data["children"] + [child] 1127 | 1128 | filled_data = self._fill_data(data) 1129 | self.project.put( 1130 | endpoint="categories/" + str(self.ids[name]["id"]), 1131 | data=filled_data, 1132 | ) 1133 | elif "new_name" in data: 1134 | filled_data = self._fill_data(data) 1135 | self.project.put( 1136 | endpoint="categories/" + str(self.ids[name]["id"]), 1137 | data=filled_data, 1138 | ) 1139 | name = data["new_name"] 1140 | 1141 | elif name not in self.ids and not modify_only: 1142 | filled_data = self._fill_data(data) 1143 | self.project.post(endpoint="categories", data=filled_data) 1144 | else: 1145 | continue 1146 | 1147 | self.reload() 1148 | cat_data = {} 1149 | for data in data_list: 1150 | if "new_name" in data: 1151 | name = data["new_name"] 1152 | else: 1153 | name = data["name"] 1154 | if name in self.ids: 1155 | cat_data[name] = self.ids[name] 1156 | return cat_data 1157 | 1158 | def rename(self, name, new_name): 1159 | """ 1160 | Renames an existing category. 1161 | 1162 | Args: 1163 | name: Name of existing parent category. 1164 | new_name: New name for the parent category. 1165 | 1166 | Raises: 1167 | KeyError: If the category does not exist. 1168 | """ 1169 | if name not in self.ids: 1170 | raise KeyError("Cannot rename a category which does not exist", name) 1171 | else: 1172 | children = list(self.ids[name]["children"]) 1173 | self.upload( 1174 | name=name, 1175 | new_name=new_name, 1176 | id=self.ids[name]["id"], 1177 | multiple=self.ids[name]["multiple"], 1178 | children=children, 1179 | ) 1180 | 1181 | def delete(self, name): 1182 | """ 1183 | Deletes an entire parent category or subcategory. 1184 | 1185 | Args: 1186 | name: Category name if you wish to delete an entire parent category or a dictionary of the form {name: parentname, children: [child1todelete, child2todelete, ...]}, if you wish to delete a subcategory or list of subcateogries. 1187 | """ 1188 | self.delete_all([name]) 1189 | 1190 | def delete_all(self, names): 1191 | """ 1192 | Deletes a list of categories or subcategories. 1193 | If you're deleting the entire parent category then you can pass in a simple list of parent category names. If you're deleting subcategories, then you need to pass in a list of dictionaries in the format: {name: parentname, children: [child1todelete, child2todelete, ...]} 1194 | 1195 | Args: 1196 | names: List of parent category names to delete or dictionary with subcategories to delete. 1197 | """ 1198 | for item in names: 1199 | if isinstance(item, str): 1200 | if item in self.ids: 1201 | self.project.delete( 1202 | endpoint="categories/" + str(self.ids[item]["id"]) 1203 | ) 1204 | elif isinstance(item, dict): 1205 | if item["name"] in self.ids: 1206 | name = item["name"] 1207 | updated_children = [] 1208 | existing_children = list(self.ids[name]["children"]) 1209 | 1210 | for child in existing_children: 1211 | if child not in item["children"]: 1212 | updated_children.append(child) 1213 | 1214 | data = { 1215 | "name": name, 1216 | "children": updated_children, 1217 | "multiple": self.ids[name]["multiple"], 1218 | } 1219 | 1220 | filled_data = self._fill_data(data) 1221 | self.project.put( 1222 | endpoint="categories/" + str(self.ids[name]["id"]), 1223 | data=filled_data, 1224 | ) 1225 | self.reload() 1226 | 1227 | def clear_all_in_project(self): 1228 | """ WARNING: This is the nuclear option. Do not use lightly. It deletes ALL categories in the project. """ 1229 | for cat in self.ids: 1230 | self.delete(self.ids[cat]["id"]) 1231 | 1232 | def _fill_data(self, data): 1233 | """ internal use """ 1234 | filled = {} 1235 | 1236 | if "id" in data: 1237 | filled["id"] = data["id"] 1238 | if "new_name" in data: 1239 | filled["id"] = self.ids[data["name"]]["id"] 1240 | filled["name"] = data["new_name"] 1241 | else: 1242 | filled["name"] = data["name"] 1243 | 1244 | if "multiple" in data: 1245 | filled["multiple"] = data["multiple"] 1246 | else: 1247 | filled["multiple"] = True 1248 | 1249 | filled["children"] = [] 1250 | for child in data["children"]: 1251 | if (data["name"] in self.ids) and ( 1252 | child in self.ids[data["name"]]["children"] 1253 | ): 1254 | child_id = self.ids[data["name"]]["children"][child] 1255 | else: 1256 | child_id = None 1257 | filled["children"].append({"name": child, "id": child_id}) 1258 | return json.dumps(filled) 1259 | 1260 | 1261 | class BWRules(BWResource): 1262 | """ 1263 | This class provides an interface for Rule operations within a prescribed project. 1264 | 1265 | Attributes: 1266 | queries: All queries in the project - handeled at the class level to prevent repetitive API calls. This is a BWQueries object. 1267 | tags: All tags in the project - handeled at the class level to prevent repetitive API calls. This is a BWTags object. 1268 | categories: All categories in the project - handeled at the class level to prevent repetitive API calls. This is a BWCategories object. 1269 | """ 1270 | 1271 | general_endpoint = "rules" 1272 | specific_endpoint = "rules" 1273 | resource_type = "rules" 1274 | 1275 | def __init__(self, bwproject): 1276 | """ 1277 | Creates a BWRules object. 1278 | 1279 | Args: 1280 | bwproject: Brandwatch project. This is a BWProject object. 1281 | """ 1282 | super(BWRules, self).__init__(bwproject) 1283 | # store queries, tags and cats as a rule attribute so you don't have to reload a million times 1284 | self.queries = BWQueries(self.project) 1285 | self.tags = self.queries.tags 1286 | self.categories = self.queries.categories 1287 | 1288 | def upload_all(self, data_list, create_only=False, modify_only=False): 1289 | """ 1290 | Uploads a list of rules. 1291 | Args: 1292 | data_list: A list of dictionaries, where each dictionaries contains a name, ruleAction and (optional but recommended) filters. It is best practice to first call rule_action() and filters() to generate error checked versions of these two required dictionaries. Optionally, you can also pass in enabled (boolean: default True), scope (string. default based on presence or absence of term queryName) and/or backfill (boolean. default False. To apply the rule to already existing mentions, set backfill to True). 1293 | create_only: If True and the category already exists, no action will be triggered - Optional. Defaults to False. 1294 | modify_only: If True and the category does not exist, no action will be triggered - Optional. Defaults to False. 1295 | 1296 | Raises: 1297 | KeyError: If an item in the data_list does not include a name. 1298 | KeyError: If an item in the data_list does not include a ruleAction. 1299 | 1300 | """ 1301 | 1302 | rules = [] 1303 | 1304 | for rule in data_list: 1305 | rule = {**rule} 1306 | if "filter" in rule: 1307 | rule["filter"] = { 1308 | **rule["filter"], 1309 | "projectId": self.project.project_id, 1310 | } 1311 | rules.append(rule) 1312 | 1313 | rules_to_id = super(BWRules, self).upload_all( 1314 | rules, create_only=False, modify_only=False 1315 | ) 1316 | 1317 | for rule in rules: 1318 | if "backfill" in rule and rule["backfill"]: 1319 | self.project.post( 1320 | endpoint="bulkactions/rule/" + str(rules_to_id[rule["name"]]) 1321 | ) 1322 | 1323 | def rename(self, name, new_name): 1324 | """ 1325 | Renames an existing resource. 1326 | 1327 | Args: 1328 | name: Name of existing resource. 1329 | new_name: New name for the resource. 1330 | 1331 | Raises: 1332 | KeyError: If the resource does not exist. 1333 | """ 1334 | if not self.check_resource_exists(name): 1335 | raise KeyError( 1336 | "Cannot rename a " + self.resource_type + " which does not exist", name 1337 | ) 1338 | else: 1339 | info = self.get(name=name) 1340 | rule = {} 1341 | rule["ruleAction"] = self.rule_action(**info["ruleAction"]) 1342 | if info["filter"]["queryName"] == "Whole Project": 1343 | info["filter"].pop("queryName") 1344 | rule["filter"] = self.filters(**info["filter"]) 1345 | self.upload(name=name, new_name=new_name, **rule) 1346 | 1347 | def rule_action(self, action, setting): 1348 | """ 1349 | Formats rule action into dictionary and checks that its contents are valid. 1350 | If the action is category or tag related and the cat or tag doesn't yet exist, we upload it here. 1351 | 1352 | Args: 1353 | action: Action to be taken by the rule. See the list "mutable" in filters.py for a full list of options. 1354 | setting: Setting for the action. E.g. If action is addCategories or removeCategories: setting = {parent:[child]}. 1355 | 1356 | Raises: 1357 | KeyError: If the action input is invalid. 1358 | KeyError: If the setting input is invalid. 1359 | 1360 | Returns: 1361 | A dictionary of the form {action: setting} 1362 | """ 1363 | if action in ["addCategories", "removeCategories"]: 1364 | # the following loop is only one iteration 1365 | for category in setting: 1366 | parent = category 1367 | children = setting[category] 1368 | 1369 | self.categories.upload(name=parent, children=children) 1370 | setting = [] 1371 | for child in children: 1372 | setting.append(self.categories.ids[parent]["children"][child]) 1373 | 1374 | elif action in ["addTag", "removeTag"]: 1375 | for s in setting: 1376 | self.tags.upload(name=s, create_only=True) 1377 | 1378 | if action not in filters.mutable: 1379 | raise KeyError("invalid rule action", action) 1380 | elif not self._valid_action_input(action, setting): 1381 | raise KeyError("invalid setting", setting) 1382 | 1383 | return {action: setting} 1384 | 1385 | def filters(self, queryName="", **kwargs): 1386 | """ 1387 | Prepares rule filters in a dictionary. 1388 | 1389 | Args: 1390 | queryName: List of queries which the rule will be applied to. 1391 | kwargs: Any number of filters, passed through in the form filter_type = filter_setting. For a full list of filters see filters.py. 1392 | 1393 | Returns: 1394 | A dictionary of filters in the form {filter1type: filter1setting, filter2type: filter2setting, ...} 1395 | """ 1396 | fil = {} 1397 | if queryName != "": 1398 | if not isinstance(queryName, list): 1399 | queryName = [queryName] 1400 | fil["queryId"] = [] 1401 | for query in queryName: 1402 | fil["queryId"].append(self.queries.get_resource_id(query)) 1403 | 1404 | for param in kwargs: 1405 | setting = self._name_to_id(param, kwargs[param]) 1406 | fil[param] = setting 1407 | return fil 1408 | 1409 | def rule(self, name, action, filter, **kwargs): 1410 | """ 1411 | When using upload_all(), it may be useful to use this function first to keep rule dictionaries organized and formatted correctly. 1412 | 1413 | Args: 1414 | name: Rule name. 1415 | action: Rule action. It is best practice to first call rule_action() to generate an error checked version of this required dictionary. 1416 | filter: Rule filter. It is best practice to first call filters() to generate a formatted version of this required dictionary. 1417 | kwargs: Additional rule information - Optional. Accepted keyword arguments are enabled (boolean: default True), scope (string. default based on presence or absence of term queryName) and/or backfill (boolean. default False. To apply the rule to already existing mentions, set backfill to True). 1418 | 1419 | Returns: 1420 | Dictionary with all rule information, ready to be uploaded. 1421 | """ 1422 | rule = {} 1423 | rule["name"] = name 1424 | rule["ruleAction"] = action 1425 | rule["filter"] = filter 1426 | if "scope" in kwargs: 1427 | rule["scope"] = kwargs["scope"] 1428 | if "backfill" in kwargs: 1429 | rule["backfill"] = kwargs["backfill"] 1430 | if "enabled" in kwargs: 1431 | rule["enabled"] = kwargs["enabled"] 1432 | return rule 1433 | 1434 | def clear_all_in_project(self): 1435 | """ WARNING: This is the nuclear option. Do not use lightly. It deletes ALL rules in the project. """ 1436 | for resource_id in self.names.keys(): 1437 | self.project.delete(endpoint="rules/" + str(resource_id)) 1438 | self.reload() 1439 | 1440 | def get(self, name=None): 1441 | """ 1442 | Retrieves all information for a list of existing rules, and formats each rule in the following way {"name":name, "queries":queries, "filter":filters, "ruleAction":ruleAction} 1443 | Returns: 1444 | List of dictionaries in the format {"name":name, "queries":queries, "filter":filters, "ruleAction":ruleAction} 1445 | """ 1446 | if not name: 1447 | ruledata = self.project.get(endpoint="rules") 1448 | if "errors" not in ruledata: 1449 | ruledata = ruledata["results"] 1450 | else: 1451 | exit() 1452 | elif not self.check_resource_exists(name): 1453 | raise KeyError("Could not find " + self.resource_type + ": " + name) 1454 | else: 1455 | resource_id = self.get_resource_id(name) 1456 | ruledata = self.project.get( 1457 | endpoint=self.specific_endpoint + "/" + str(resource_id) 1458 | ) 1459 | ruledata = [ruledata] 1460 | 1461 | rules = [] 1462 | for rule in ruledata: 1463 | name = rule["name"] 1464 | queryIds = rule["filter"]["queryId"] 1465 | if queryIds is None: # scope = project, so specific queries are not listed 1466 | queries = "Whole Project" 1467 | else: 1468 | queries = [self.queries.names[q] for q in queryIds] 1469 | filters = {"queryName": queries} 1470 | for fil in rule["filter"]: 1471 | value = rule["filter"].get(fil) 1472 | if value is not None and fil != "queryId": 1473 | filters[fil] = self._id_to_name(fil, value) 1474 | 1475 | ruleAction = {} 1476 | for action in rule["ruleAction"]: 1477 | value = rule["ruleAction"].get(action) 1478 | if value is not None: 1479 | ruleAction["action"] = action 1480 | ruleAction["setting"] = self._id_to_name(action, value) 1481 | break 1482 | 1483 | rules.append({"name": name, "filter": filters, "ruleAction": ruleAction}) 1484 | if len(rules) == 1: 1485 | return rules[0] 1486 | else: 1487 | return rules 1488 | 1489 | def _fill_data(self, data): 1490 | """ internal use """ 1491 | filled = {} 1492 | if ("name" not in data) or ("ruleAction" not in data): 1493 | raise KeyError("Need name to and ruleAction to upload rule", data) 1494 | 1495 | # for PUT calls, need id, projectName, queryName in addition to the rest of the data below 1496 | if self.check_resource_exists(data["name"]): 1497 | filled["id"] = self.get_resource_id(data["name"]) 1498 | filled["projectName"] = ( 1499 | data["projectName"] 1500 | if ("projectName" in data) 1501 | else self.project.project_name 1502 | ) 1503 | filled["queryName"] = data["queryName"] if ("queryName" in data) else None 1504 | 1505 | if "new_name" in data: 1506 | filled["name"] = data["new_name"] 1507 | else: 1508 | filled["name"] = data["name"] 1509 | 1510 | filled["enabled"] = data["enabled"] if ("enabled" in data) else True 1511 | filled["filter"] = data["filter"] if ("filter" in data) else {} 1512 | filled["ruleAction"] = data["ruleAction"] 1513 | filled["projectId"] = self.project.project_id 1514 | 1515 | # validating the query search - comment this out to skip validation 1516 | if "search" in filled["filter"]: 1517 | self.project.validate_rule_search( 1518 | query=filled["filter"]["search"], language="en" 1519 | ) 1520 | 1521 | if "scope" in data: 1522 | filled["scope"] = data["scope"] 1523 | elif "queryId" in data["filter"]: 1524 | filled["scope"] = "query" 1525 | else: 1526 | filled["scope"] = "project" 1527 | 1528 | return json.dumps(filled) 1529 | 1530 | def _name_to_id(self, attribute, setting): 1531 | if isinstance(setting, int): 1532 | return setting 1533 | 1534 | elif isinstance(setting, list): 1535 | try: 1536 | return [int(i) for i in setting] 1537 | except ValueError: 1538 | pass 1539 | 1540 | elif attribute in ["category", "xcategory"]: 1541 | # setting is a dictionary with one key-value pair, so this loop iterates only once 1542 | # but is necessary to extract the values in the dictionary 1543 | for category in setting: 1544 | parent = category 1545 | child = setting[category][0] 1546 | return self.categories.ids[parent]["children"][child] 1547 | 1548 | elif attribute in [ 1549 | "parentCategory", 1550 | "xparentCategory", 1551 | "parentCategories", 1552 | "categories", 1553 | ]: 1554 | # plural included for get_charts syntax 1555 | if not isinstance(setting, list): 1556 | setting = [setting] 1557 | ids = [] 1558 | for s in setting: 1559 | ids.append(self.categories.ids[s]["id"]) 1560 | return ids 1561 | 1562 | elif attribute in ["tag", "xtag", "tags"]: 1563 | # plural included for get_charts syntax 1564 | if not isinstance(setting, list): 1565 | setting = [setting] 1566 | ids = [] 1567 | for s in setting: 1568 | ids.append(self.tags.get_resource_id(s)) 1569 | return ids 1570 | 1571 | elif attribute in ["authorGroup", "xauthorGroup"]: 1572 | authorlists = BWAuthorLists(self.project) 1573 | if not isinstance(setting, list): 1574 | setting = [setting] 1575 | ids = [] 1576 | for s in setting: 1577 | ids.append(authorlists.get(s)["id"]) 1578 | return ids 1579 | 1580 | elif attribute in [ 1581 | "locationGroup", 1582 | "xlocationGroup", 1583 | "authorLocationGroup", 1584 | "xauthorLocationGroup", 1585 | ]: 1586 | locationlists = BWLocationLists(self.project) 1587 | if not isinstance(setting, list): 1588 | setting = [setting] 1589 | ids = [] 1590 | for s in setting: 1591 | ids.append(locationlists.get(s)["id"]) 1592 | return ids 1593 | 1594 | elif attribute in ["siteGroup", "xsiteGroup"]: 1595 | sitelists = BWSiteLists(self.project) 1596 | if not isinstance(setting, list): 1597 | setting = [setting] 1598 | ids = [] 1599 | for s in setting: 1600 | ids.append(sitelists.get(s)["id"]) 1601 | return ids 1602 | 1603 | else: 1604 | return setting 1605 | 1606 | def _valid_action_input(self, action, setting): 1607 | """ internal use """ 1608 | if not isinstance(setting, filters.mutable[action]): 1609 | return False 1610 | if ( 1611 | action in filters.mutable_options 1612 | and setting not in filters.mutable_options[action] 1613 | ): 1614 | return False 1615 | else: 1616 | return True 1617 | 1618 | def _id_to_name(self, attribute, setting): 1619 | if not setting or isinstance(setting, str): 1620 | return setting 1621 | 1622 | if isinstance(setting, list) and isinstance(setting[0], str): 1623 | return setting 1624 | 1625 | elif attribute in ["tag", "xtag", "addTag", "removeTag"]: 1626 | return self.tags.get_resource_id(setting) 1627 | 1628 | elif attribute in [ 1629 | "category", 1630 | "xcategory", 1631 | "addCategories", 1632 | "removeCategories", 1633 | ]: 1634 | names = {} 1635 | subcats = [] 1636 | 1637 | for category in self.categories.ids: 1638 | for subcategory in self.categories.ids[category]["children"]: 1639 | if ( 1640 | self.categories.ids[category]["children"][subcategory] 1641 | in setting 1642 | ): 1643 | subcats.append(subcategory) 1644 | if subcats: 1645 | names[category] = subcats 1646 | subcats = [] 1647 | 1648 | return names 1649 | 1650 | elif attribute == "parentCategory" or attribute == "xparentCategory": 1651 | for category in self.categories.ids: 1652 | for cat in setting: 1653 | if cat == self.categories.ids[category]["id"]: 1654 | return category 1655 | 1656 | elif attribute == "authorGroup" or attribute == "xauthorGroup": 1657 | resource_obj = BWAuthorLists(self.project) 1658 | for resource_id, resource_name in resource_obj.names.items(): 1659 | for aulist in setting: 1660 | if resource_id == aulist: 1661 | return resource_name 1662 | 1663 | elif attribute == "locationGroup" or attribute == "xlocationGroup": 1664 | resource_obj = BWLocationLists(self.project) 1665 | for resource_id, resource_name in resource_obj.names.items(): 1666 | for aulist in setting: 1667 | if resource_id == aulist: 1668 | return resource_name 1669 | 1670 | elif attribute == "authorLocationGroup" or attribute == "xauthorLocationGroup": 1671 | resource_obj = BWLocationLists(self.project) 1672 | for resource_id, resource_name in resource_obj.names.items(): 1673 | for aulist in setting: 1674 | if resource_id == aulist: 1675 | return resource_name 1676 | 1677 | elif attribute == "siteGroup" or attribute == "xsiteGroup": 1678 | resource_obj = BWSiteLists(self.project) 1679 | for resource_id, resource_name in resource_obj.names.items(): 1680 | for aulist in setting: 1681 | if resource_id == aulist: 1682 | return resource_name 1683 | 1684 | else: 1685 | return setting 1686 | 1687 | 1688 | class BWSignals(BWResource): 1689 | """ 1690 | This class provides an interface for signals operations within a prescribed project (e.g. uploading, downloading). 1691 | 1692 | Attributes: 1693 | queries: All queries in the project - handeled at the class level to prevent repetitive API calls. This is a BWQueries object. 1694 | tags: All tags in the project - handeled at the class level to prevent repetitive API calls. This is a BWTags object. 1695 | categories: All categories in the project - handeled at the class level to prevent repetitive API calls. This is a BWCategories object. 1696 | """ 1697 | 1698 | general_endpoint = "signals/groups" 1699 | specific_endpoint = "signals/groups" 1700 | resource_type = "signals" 1701 | 1702 | def __init__(self, bwproject): 1703 | """ 1704 | Creates a BWSignals object. 1705 | 1706 | Args: 1707 | bwproject: Brandwatch project. This is a BWProject object. 1708 | """ 1709 | super(BWSignals, self).__init__(bwproject) 1710 | self.queries = BWQueries(self.project) 1711 | self.tags = self.queries.tags 1712 | self.categories = self.queries.categories 1713 | 1714 | def rename(self, name, new_name): 1715 | """ 1716 | Renames an existing resource. 1717 | 1718 | Args: 1719 | name: Name of existing resource. 1720 | new_name: New name for the resource. 1721 | 1722 | Raises: 1723 | KeyError: If the resource does not exist. 1724 | """ 1725 | if not self.get_resource_id(name): 1726 | raise KeyError( 1727 | "Cannot rename a " + self.resource_type + " which does not exist", name 1728 | ) 1729 | else: 1730 | info = self.get(name=name) 1731 | info.pop("name") 1732 | info["queries"] = info.pop("queryIds") 1733 | self.upload(name=name, new_name=new_name, **info) 1734 | 1735 | def _fill_data(self, data): 1736 | filled = {} 1737 | 1738 | if ( 1739 | ("name" not in data) 1740 | or ("queries" not in data) 1741 | or ("subscribers" not in data) 1742 | ): 1743 | raise KeyError( 1744 | "Need name, queries and subscribers to create a signal", data 1745 | ) 1746 | 1747 | for subscriber in data["subscribers"]: 1748 | if ( 1749 | ("emailAddress" not in subscriber) 1750 | or ("notificationThreshold" not in subscriber) 1751 | or (subscriber["notificationThreshold"] not in [1, 2, 3]) 1752 | ): 1753 | raise KeyError( 1754 | "subscribers must be in the format {emailAddress: emailaddress, notificationThreshold: 1/2/3} where the notificationThreshold must be 1 (all signals), 2 (medium - high priority signals) or 3 (only high priority signals)", 1755 | subscriber, 1756 | ) 1757 | 1758 | if self.get_resource_id(data["name"]): 1759 | filled["id"] = self.get_resource_id(data["name"]) 1760 | if "new_name" in data: 1761 | filled["name"] = data["new_name"] 1762 | else: 1763 | filled["name"] = data["name"] 1764 | 1765 | filled["queryIds"] = [] 1766 | for query in data["queries"]: 1767 | if isinstance(query, int): 1768 | filled["queryIds"].append(query) 1769 | else: 1770 | filled["queryIds"].append(self.queries.get_resource_id(query)) 1771 | 1772 | filled["subscribers"] = data["subscribers"] 1773 | 1774 | for param in data: 1775 | filled.update(self._name_to_id(param, data[param])) 1776 | 1777 | return json.dumps(filled) 1778 | 1779 | def _name_to_id(self, attribute, setting): 1780 | """ internal use """ 1781 | ids = [] 1782 | if attribute in ["includeCategoryIds", "excludeCategoryIds"]: 1783 | for category in setting: 1784 | if not isinstance(category, int): 1785 | # already in ID form 1786 | raise KeyError( 1787 | "Must pass in ids with " 1788 | + attribute 1789 | + " parameter, or use names and the appropriate category/xcategory or parentCategory/xparentCategory parameter." 1790 | ) 1791 | return {attribute: setting} 1792 | 1793 | elif attribute in ["category", "xcategory"]: 1794 | for category in setting: 1795 | if isinstance(category, int): 1796 | # already in ID form 1797 | ids.append(category) 1798 | else: 1799 | parent = category 1800 | for child in setting[category]: 1801 | ids.append(self.categories.ids[parent]["children"][child]) 1802 | 1803 | if attribute == "category": 1804 | return {"includeCategoryIds": ids} 1805 | else: 1806 | return {"excludeCategoryIds": ids} 1807 | 1808 | elif attribute in ["parentCategory", "xparentCategory"]: 1809 | if not isinstance(setting, list): 1810 | setting = [setting] 1811 | 1812 | for category in setting: 1813 | if isinstance(category, int): 1814 | # already in ID form 1815 | ids.append(category) 1816 | else: 1817 | ids.append(self.categories.ids[category]["id"]) 1818 | 1819 | if attribute == "parentCategory": 1820 | return {"includeCategoryIds": ids} 1821 | else: 1822 | return {"excludeCategoryIds": ids} 1823 | 1824 | elif attribute in ["tag", "xtag", "includeTagIds", "excludeTagIds"]: 1825 | if not isinstance(setting, list): 1826 | setting = [setting] 1827 | for tag in setting: 1828 | if isinstance(tag, int): 1829 | # already in ID form 1830 | ids.append(tag) 1831 | else: 1832 | ids.append(self.tags.get_resource_id(tag)) 1833 | 1834 | if attribute in ["tag", "includeTagIds"]: 1835 | return {"includeTagIds": ids} 1836 | else: 1837 | return {"excludeTagIds": ids} 1838 | else: 1839 | return {} 1840 | --------------------------------------------------------------------------------