Showing preview only (495K chars total). Download the full file or copy to clipboard to get everything.
Repository: ponyriders/django-amazon-price-monitor
Branch: master
Commit: c45d9f48a5bf
Files: 118
Total size: 459.8 KB
Directory structure:
gitextract_h5nbuqa_/
├── .coveragerc
├── .editorconfig
├── .gitignore
├── .landscape.yaml
├── .travis.yml
├── CONTRIBUTING.rst
├── HISTORY.rst
├── LICENSE
├── MANIFEST.in
├── Makefile
├── README.rst
├── docker/
│ ├── .gitignore
│ ├── base/
│ │ └── Dockerfile
│ ├── compose.env
│ ├── docker-compose.yml
│ └── web/
│ ├── Dockerfile
│ ├── celery_run.sh
│ ├── django-amazon-price-monitor/
│ │ ├── price_monitor/
│ │ │ └── __init__.py
│ │ └── setup.py
│ ├── project/
│ │ ├── glue/
│ │ │ ├── __init__.py
│ │ │ ├── celery.py
│ │ │ ├── settings.py
│ │ │ ├── urls.py
│ │ │ └── wsgi.py
│ │ ├── glue_auth/
│ │ │ ├── __init__.py
│ │ │ ├── fixtures/
│ │ │ │ └── admin.json
│ │ │ ├── models.py
│ │ │ ├── templates/
│ │ │ │ ├── glue_auth/
│ │ │ │ │ ├── base.html
│ │ │ │ │ └── login.html
│ │ │ │ └── price_monitor/
│ │ │ │ └── angular_index_view.html
│ │ │ └── urls.py
│ │ ├── manage.py
│ │ └── requirements.pip
│ └── web_run.sh
├── docs/
│ └── price_monitor.product_advertising_api.tasks.activity.violet.html
├── hooks/
│ └── pre-commit
├── price_monitor/
│ ├── __init__.py
│ ├── admin.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── renderers/
│ │ │ ├── PriceChartPNGRenderer.py
│ │ │ └── __init__.py
│ │ ├── serializers/
│ │ │ ├── EmailNotificationSerializer.py
│ │ │ ├── PriceSerializer.py
│ │ │ ├── ProductSerializer.py
│ │ │ ├── SubscriptionSerializer.py
│ │ │ └── __init__.py
│ │ ├── urls.py
│ │ └── views/
│ │ ├── EmailNotificationListView.py
│ │ ├── PriceListView.py
│ │ ├── ProductCreateRetrieveUpdateDestroyAPIView.py
│ │ ├── ProductListView.py
│ │ ├── SubscriptionListView.py
│ │ ├── SubscriptionRetrieveView.py
│ │ ├── __init__.py
│ │ └── mixins/
│ │ ├── ProductFilteringMixin.py
│ │ └── __init__.py
│ ├── app_settings.py
│ ├── forms.py
│ ├── locale/
│ │ └── de/
│ │ └── LC_MESSAGES/
│ │ ├── django.mo
│ │ └── django.po
│ ├── management/
│ │ ├── __init__.py
│ │ └── commands/
│ │ ├── __init__.py
│ │ ├── price_monitor_batch_create_products.py
│ │ ├── price_monitor_clean_db.py
│ │ ├── price_monitor_recreate_product.py
│ │ ├── price_monitor_search.py
│ │ └── price_monitor_send_test_mail.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ ├── 0002_add_min_max_fk_to_product.py
│ │ ├── 0003_datamigration_for_min_max_cur_fks.py
│ │ ├── 0004_make_price_and_currency_nullable.py
│ │ ├── 0005_product_artist.py
│ │ └── __init__.py
│ ├── models/
│ │ ├── EmailNotification.py
│ │ ├── Price.py
│ │ ├── Product.py
│ │ ├── Subscription.py
│ │ ├── __init__.py
│ │ └── mixins/
│ │ ├── PublicIDMixin.py
│ │ └── __init__.py
│ ├── product_advertising_api/
│ │ ├── __init__.py
│ │ ├── api.py
│ │ └── tasks.py
│ ├── static/
│ │ └── price_monitor/
│ │ ├── angular/
│ │ │ ├── angular-django-rest-resource.js
│ │ │ └── angular-responsive-images.js
│ │ ├── app/
│ │ │ ├── css/
│ │ │ │ ├── app.css
│ │ │ │ └── xeditable.css
│ │ │ ├── js/
│ │ │ │ ├── app.js
│ │ │ │ ├── controller/
│ │ │ │ │ ├── emailnotification-create-ctrl.js
│ │ │ │ │ ├── main-ctrl.js
│ │ │ │ │ ├── product-delete-ctrl.js
│ │ │ │ │ ├── product-detail-ctrl.js
│ │ │ │ │ └── product-list-ctrl.js
│ │ │ │ ├── filters.js
│ │ │ │ └── server-connector.js
│ │ │ └── partials/
│ │ │ ├── emailnotification-create.html
│ │ │ ├── product-delete.html
│ │ │ ├── product-detail.html
│ │ │ └── product-list.html
│ │ ├── bootstrap/
│ │ │ └── css/
│ │ │ ├── bootstrap-theme.css
│ │ │ └── bootstrap.css
│ │ └── css/
│ │ ├── base.css
│ │ └── inline-form.css
│ ├── tasks.py
│ ├── templates/
│ │ └── price_monitor/
│ │ └── angular_index_view.html
│ ├── urls.py
│ ├── utils.py
│ └── views.py
├── setup.cfg
├── setup.py
├── tests/
│ ├── __init__.py
│ ├── product_advertising_api/
│ │ ├── __init__.py
│ │ ├── data.py
│ │ └── test_api.py
│ ├── settings.py
│ ├── test_product.py
│ └── test_utils.py
└── tox.ini
================================================
FILE CONTENTS
================================================
================================================
FILE: .coveragerc
================================================
[run]
omit = price_monitor/migrations/*
================================================
FILE: .editorconfig
================================================
[*.rst]
indent_style = tab
indent_size = 4
[*.json]
indent_style = space
indent_size = 4
[Makefile]
indent_style = tab
indent_size = 4
================================================
FILE: .gitignore
================================================
*.log
*.pot
*.pyc
.coverage
.cache
.env
.idea
.project
.pydevproject
.settings
.tox
.vscode
build
dist
django_amazon_price_monitor.egg-info
price_monitor/management/commands/price_monitor_dev.py
================================================
FILE: .landscape.yaml
================================================
doc-warnings: yes
max-line-length: 160
uses:
- django
- celery
ignore-paths:
- docs
- hooks
- price_monitor/migrations
pylint:
disable:
- invalid-encoded-data
- model-missing-unicode
python-targets:
- 3
================================================
FILE: .travis.yml
================================================
language: python
sudo: false
matrix:
include:
- python: 3.4
env:
- TOXENV=py34-django1.8
- python: 3.4
env:
- TOXENV=py34-django1.9
- python: 3.4
env:
- TOXENV=py34-django1.10
- python: 3.4
env:
- TOXENV=py34-django1.11
- python: 3.5
env:
- TOXENV=py35-django1.9
- python: 3.5
env:
- TOXENV=py35-django1.10
- python: 3.5
env:
- TOXENV=py35-django1.11
- python: 3.6
env:
- TOXENV=py36-django1.11
install:
- pip install codecov tox
script:
- tox
after_success:
- codecov
notifications:
email: false
deploy:
provider: pypi
user: ponyriders
password:
secure: n04DQkYdiwg+XLVfJd/O3Jil7kUV1GeLK/gqTgAjOlpXmChhI3+2Xzg8hKoWYmtxXLqZu0zpvepHVi/y5Xz2R1va+eNjnQ9XKzZBt6t40+YaMKpUTZsP0fGocJr0imxuqmOOV8YJ7cZ3r4eX+4aUMMq2tE2j6b37MczTfBw1YmM=
distributions: sdist bdist_wheel
on:
tags: true
repo: ponyriders/django-amazon-price-monitor
condition: "$TOXENV = py35-django1.11"
================================================
FILE: CONTRIBUTING.rst
================================================
Contributing
============
If you like to lend us a hand, feel free to contribute code to the project. Pick an issue or add what you miss.
Please remember that we are only humans and offer this in our spare time.
Fork the repo, then clone it:
::
git clone git@github.com:your-username/django-amazon-price-monitor.git
Ensure the tests run:
tox
Make your change. Add tests for your change. Make the tests pass:
tox
Push to your fork and `submit a pull request`_.
At this point you're waiting on us. We like to at least comment on pull requests
and we may suggest some changes or improvements or alternatives.
Some things that will increase the chance that your pull request is accepted:
* Write tests.
* Follow the PEP8 style guide.
* Write a `good commit message`_.
.. _code of conduct: https://thoughtbot.com/open-source-code-of-conduct
.. _submit a pull request: https://github.com/ponyriders/django-amazon-price-monitor/compare/
.. _good commit message: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
================================================
FILE: HISTORY.rst
================================================
Change Log
==========
TBA
---
**Maintenance**
- updated the following packages
- ``Celery`` from ``3`` to ``4``, **the setting** ``BROKER_URL`` **is now named** ``CELERY_BROKER_URL``
- ``Django`` up to ``1.11``
- ``CairoSVG`` is not pinned to version below ``2`` any more, with this we drop support for Python ``3.3`` as it is not compatible
- dropped support for ``Python 3.3``
**Bugfixes:**
- fixed handling of ISBN-13 values in the ISBN-10 return value from Amazon Product Advertising API `#121 <https://github.com/ponyriders/django-amazon-price-monitor/issues/121>`__ (`PR#122 <https://github.com/ponyriders/django-amazon-price-monitor/pull/122>`__)
`0.7 <https://pypi.python.org/pypi/django-amazon-price-monitor/0.7>`__
----------------------------------------------------------------------
**Features:**
- footer can now be extended through template block *footer*
- product addition in frontend improved `#79 <https://github.com/ponyriders/django-amazon-price-monitor/issues/79>`__ (`PR#104 <https://github.com/ponyriders/django-amazon-price-monitor/pull/104>`__)
- removed ``urlpatterns`` to please Django 1.10 deprecation
- added docker setup for development (`PR#101 <https://github.com/ponyriders/django-amazon-price-monitor/pull/101>`__)
- list products with audience rating 18+ in notification mail if region is Germany and product is also 18+ `#92 <https://github.com/ponyriders/django-amazon-price-monitor/issues/92>`__ (`PR#93 <https://github.com/ponyriders/django-amazon-price-monitor/pull/93>`__)
**Bugfixes:**
- now catching parsing errors of returned XML from Amazon API `#96 <https://github.com/ponyriders/django-amazon-price-monitor/issues/96>`__
- fixed date range of displayed prices in price graph `#90 <https://github.com/ponyriders/django-amazon-price-monitor/issues/90>`__
- fixed display of old prices of price graph `#97 <https://github.com/ponyriders/django-amazon-price-monitor/issues/97>`__
- updated to latest ``python-dateutil`` version, somehow refs `#95 <https://github.com/ponyriders/django-amazon-price-monitor/issues/95>`__
`0.6.1 <https://pypi.python.org/pypi/django-amazon-price-monitor/0.6.1>`__
--------------------------------------------------------------------------
**Bugfixes:**
- StartupTask fails with exception `#94 <https://github.com/ponyriders/django-amazon-price-monitor/issues/94>`__
- Tests fail if today is the last day of November `#95 <https://github.com/ponyriders/django-amazon-price-monitor/issues/95>`__
`0.6 <https://pypi.python.org/pypi/django-amazon-price-monitor/0.6>`__
----------------------------------------------------------------------
**Features:**
- djangorestframework 3.2 compatibility `#86 <https://github.com/ponyriders/django-amazon-price-monitor/issues/86>`__ (`PR#88 <https://github.com/ponyriders/django-amazon-price-monitor/pull/88>`__)
**Bugfixes:**
- FindProductsToSynchronizeTask is rescheduled twice or more `#89 <https://github.com/ponyriders/django-amazon-price-monitor/issues/89>`__ (`PR#91 <https://github.com/ponyriders/django-amazon-price-monitor/pull/91>`__)
- Unable to parse 2015-02 to a datetime `#57 <https://github.com/ponyriders/django-amazon-price-monitor/issues/57>`__
- lots of codestyle
- minor bugfixes
`0.5 <https://pypi.python.org/pypi/django-amazon-price-monitor/0.5>`__
----------------------------------------------------------------------
**Features:**
- Add link to PM frontend in notification email `#76 <https://github.com/ponyriders/django-amazon-price-monitor/issues/76>`__
- Django 1.9 support (see `pull request #80 <https://github.com/ponyriders/django-amazon-price-monitor/pull/80>`__)
**Bugfixes:**
- FindProductsToSynchronizeTask is not always rescheduled `#61 <https://github.com/ponyriders/django-amazon-price-monitor/issues/61>`__
- Font files not included in package `#75 <https://github.com/ponyriders/django-amazon-price-monitor/issues/75>`__
- Identify as Amazon associate `#77 <https://github.com/ponyriders/django-amazon-price-monitor/issues/77>`__
**Pull requests:**
- Ensured that FindProductsToSynchronizeTask will be scheduled `#78 <https://github.com/ponyriders/django-amazon-price-monitor/pull/78>`__ (`dArignac <https://github.com/dArignac>`__)
- Django 1.9 support `#80 <https://github.com/ponyriders/django-amazon-price-monitor/pull/80>`__ (`dArignac <https://github.com/dArignac>`__)
`0.4 <https://pypi.python.org/pypi/django-amazon-price-monitor/0.4>`__
----------------------------------------------------------------------
**Features:**
- Deprecate old frontend `#73 <https://github.com/ponyriders/django-amazon-price-monitor/issues/73>`__
- Make angular the default frontend `#70 <https://github.com/ponyriders/django-amazon-price-monitor/issues/70>`__
**Bugfixes:**
- Products with the same price over graph timespae have an empty graph `#67 <https://github.com/ponyriders/django-amazon-price-monitor/issues/67>`__
- Notification of music albums `#33 <https://github.com/ponyriders/django-amazon-price-monitor/issues/33>`__
- Add artist for audio products `#71 <https://github.com/ponyriders/django-amazon-price-monitor/pull/71>`__
**Pull requests:**
- Remove old frontend `#74 <https://github.com/ponyriders/django-amazon-price-monitor/pull/74>`__ (`dArignac <https://github.com/dArignac>`__)
- Fix for empty graphs is packaged now #67 `#72 <https://github.com/ponyriders/django-amazon-price-monitor/pull/72>`__ (`mmrose <https://github.com/mmrose>`__)
`0.3b2 <https://pypi.python.org/pypi/django-amazon-price-monitor/0.3b2>`__
--------------------------------------------------------------------------
**Features:**
- Prepare for automatic releases `#68 <https://github.com/ponyriders/django-amazon-price-monitor/issues/68>`__
- Increase performance of Amazon calls `#41 <https://github.com/ponyriders/django-amazon-price-monitor/issues/41>`__
- Django 1.8 compatibility `#32 <https://github.com/ponyriders/django-amazon-price-monitor/issues/32>`__
- Data reduction and clean up `#27 <https://github.com/ponyriders/django-amazon-price-monitor/issues/27>`__
- Limit graphs `#26 <https://github.com/ponyriders/django-amazon-price-monitor/issues/26>`__
- Show highest and lowest price ever `#25 <https://github.com/ponyriders/django-amazon-price-monitor/issues/25>`__
- Implement a full-usable frontend`#8 <https://github.com/ponyriders/django-amazon-price-monitor/issues/8>`__
- Add more tests `#2 <https://github.com/ponyriders/django-amazon-price-monitor/issues/2>`__
**Bugfixes:**
- Graphs empty for some products `#65 <https://github.com/ponyriders/django-amazon-price-monitor/issues/65>`__
- Don't show other peoples price limits `#63 <https://github.com/ponyriders/django-amazon-price-monitor/issues/63>`__
- Graphs do not render correct values `#60 <https://github.com/ponyriders/django-amazon-price-monitor/issues/60>`__
- 'NoneType' object has no attribute 'url' `#59 <https://github.com/ponyriders/django-amazon-price-monitor/issues/59>`__
- Rename SynchronizeSingleProductTask `#56 <https://github.com/ponyriders/django-amazon-price-monitor/issues/56>`__
- Sync on product creation not working `#55 <https://github.com/ponyriders/django-amazon-price-monitor/issues/55>`__
- Clear old products and prices `#47 <https://github.com/ponyriders/django-amazon-price-monitor/issues/47>`__
- Deleting a product subscription does not remove it from list view `#42 <https://github.com/ponyriders/django-amazon-price-monitor/issues/42>`__
- Endless synchronization queue `#38 <https://github.com/ponyriders/django-amazon-price-monitor/issues/38>`__
- Mark unavailable products `#14 <https://github.com/ponyriders/django-amazon-price-monitor/issues/14>`__
**Closed issues:**
- Unpin beautifulsoup4==4.3.2 `#50 <https://github.com/ponyriders/django-amazon-price-monitor/issues/50>`__
**Pull requests:**
- fixed access of unavilable image urls #59 `#66 <https://github.com/ponyriders/django-amazon-price-monitor/pull/66>`__ (`dArignac <https://github.com/dArignac>`__)
- 63 subscriptions of other users `#64 <https://github.com/ponyriders/django-amazon-price-monitor/pull/64>`__ (`mmrose <https://github.com/mmrose>`__)
- Mark unavailable products `#62 <https://github.com/ponyriders/django-amazon-price-monitor/pull/62>`__ (`mmrose <https://github.com/mmrose>`__)
- Sync on product creation not working `#58 <https://github.com/ponyriders/django-amazon-price-monitor/pull/58>`__ (`dArignac <https://github.com/dArignac>`__)
- Products are now requeried after deletion in list view #42 `#54 <https://github.com/ponyriders/django-amazon-price-monitor/pull/54>`__ (`mmrose <https://github.com/mmrose>`__)
- Show highest and lowest price (#25) `#53 <https://github.com/ponyriders/django-amazon-price-monitor/pull/53>`__ (`mmrose <https://github.com/mmrose>`__)
- Now the new FKs are also set during sync #25 `#52 <https://github.com/ponyriders/django-amazon-price-monitor/pull/52>`__ (`mmrose <https://github.com/mmrose>`__)
- Adding datamigration for new min, max and current price FKs #25 `#51 <https://github.com/ponyriders/django-amazon-price-monitor/pull/51>`__ (`mmrose <https://github.com/mmrose>`__)
- Performance improvements on product API view `#49 <https://github.com/ponyriders/django-amazon-price-monitor/pull/49>`__ (`mmrose <https://github.com/mmrose>`__)
- Remove unused data`#48 <https://github.com/ponyriders/django-amazon-price-monitor/pull/48>`__ (`dArignac <https://github.com/dArignac>`__)
- Amazon query performance increase `#46 <https://github.com/ponyriders/django-amazon-price-monitor/pull/46>`__ (`dArignac <https://github.com/dArignac>`__)
- Django 1.8 compatibility `#45 <https://github.com/ponyriders/django-amazon-price-monitor/pull/45>`__ (`dArignac <https://github.com/dArignac>`__)
- Bugfix: Endless queue `#40 <https://github.com/ponyriders/django-amazon-price-monitor/pull/40>`__ (`dArignac <https://github.com/dArignac>`__)
- waffle.io Badge `#37 <https://github.com/ponyriders/django-amazon-price-monitor/pull/37>`__ (`waffle-iron <https://github.com/waffle-iron>`__)
Pre-Releases
------------
- unfortunately everything before was not packaged and released nor tracked.
================================================
FILE: LICENSE
================================================
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, dis-
tribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the fol-
lowing conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
================================================
FILE: MANIFEST.in
================================================
include README.rst
include HISTORY.rst
recursive-include price_monitor *.html *.png *.gif *js *.css *jpg *jpeg *svg *py *eot *ttf *woff
================================================
FILE: Makefile
================================================
help:
@echo "docker-build-base: - builds the base docker image (not necessary normally as image is on docker hub)"
@echo "docker-build-web: - builds the web docker image"
docker-build-base:
docker build -t pricemonitor/base docker/base/
docker-build-web:
cp setup.py docker/web/django-amazon-price-monitor/setup.py
sed -i 's/readme = .*/readme = ""/g' docker/web/django-amazon-price-monitor/setup.py
sed -i 's/history = .*/history = ""/g' docker/web/django-amazon-price-monitor/setup.py
docker build -t pricemonitor/web docker/web/
================================================
FILE: README.rst
================================================
|Build Status| |codecov.io| |Requirements Status| |Stories in Ready| |Landscape|
.. contents:: Table of Contents
django-amazon-price-monitor
===========================
Monitors prices of Amazon products via Product Advertising API.
**Necessary settings changes for upcoming version 0.8 can be found in the** `history <https://github.com/ponyriders/django-amazon-price-monitor/blob/master/HISTORY.rst>`_ **.**
UPDATE: 0.8 will never come, see the end of life announcement below.
END OF LIFE
-----------
**!!! IMPORTANT !!!**
Since January 23rd 2019 the efficiency guidelines for using the Product Advertising API have changed dramatically.
Requests limits for the API are now calculated based on the revenue generated with the Amazon Associate account the
Product Advertising API is connected with.
You can find the details here: https://docs.aws.amazon.com/AWSECommerceService/latest/DG/TroubleshootingApplications.html#efficiency-guidelines
**For django-amazon-price-monitor this means the END OF LIFE from now on.**
But why?
We started the project to solve the issue of saving money with Amazon while having control over your data by self
hosting this project. To continue maintaining and extending the project we'd need to have enough revenue in our
associate account, which we do not have. The result is that our API account used for developing this project is blocked
and we cannot continue. Also our private little instance of pricemonitor, used by some of our friends and us, will be
shut down, too.
The changes in the guidelines make it pretty hard for small projects that do not primarily focus on generating revenue
with Amazon to continue. As the `django-amazon-price-monitor` did not get that much love in the last months and years
from us as we focused on other things, the only logic consequence for us is to pull the plug.
Thanks to all involved for participating in this journey with us!
Best
Alex + Martin
Basic structure
---------------
This is a reusable Django app that can be plugged into your project. It
consists basically of this parts:
- Models
- Frontend components
- Angular Frontend API
- Amazon API component
Models
~~~~~~
- Product
- representation of an Amazon product
- Price
- representation of a price of an Amazon product at a specific time
- Subscription
- subscribe to a product at a specific price with an email
notification
Frontend components
~~~~~~~~~~~~~~~~~~~
The frontend displays all subscribed products with additional
information and some graphs for price history.
The features are the following:
- list products
- show product details
- show product price graphs
- add subscriptions
- adjust subscription price value
- delete subscriptions
Angular Frontend API
~~~~~~~~~~~~~~~~~~~~
Simply the API consumed by AngularJS, based on Django REST Framework.
Amazon API component
~~~~~~~~~~~~~~~~~~~~
Fetches product information from Amazon Product Advertising API through
several tasks powered by Celery and weaves the data into the models.
License
-------
This software is licensed with the MIT license. So feel free to do with
it whatever you like.
Setup
-----
Prerequisites
~~~~~~~~~~~~~
+--------+----------------------+-----------------+------+
| Python | 3.4 | 3.5 | 3.6 |
+========+======================+=================+======+
| Django | 1.8, 1.9, 1.10, 1.11 | 1.9, 1.10, 1.11 | 1.11 |
+--------+----------------------+-----------------+------+
For additional used packages see `setup.py <https://github.com/ponyriders/django-amazon-price-monitor/blob/master/setup.py#L23>`__.
Included angular libraries
~~~~~~~~~~~~~~~~~~~~~~~~~~
- angular-django-rest-resource (`commit:
81d752b363668d674201c09d7a2ce6f418a44f13 <https://github.com/blacklocus/angular-django-rest-resource/tree/81d752b363668d674201c09d7a2ce6f418a44f13>`__)
Basic setup
~~~~~~~~~~~
Add the following apps to *INSTALLED\_APPS*:
::
INSTALLED_APPS = (
...
'price_monitor',
'price_monitor.product_advertising_api',
'rest_framework',
)
Then migrate:
::
python manage.py migrate
Adjust the settings appropriately, `see next chapter <#settings>`__.
Include the url configuration.
Setup celery - you'll need the beat and a worker.
Settings
~~~~~~~~
*The values of the following displayed settings are their default
values. If the value is '...' then there is no default value.*
Must have settings
^^^^^^^^^^^^^^^^^^
The following settings are absolutely necessary to the price monitor
running, please set them:
Celery
''''''
You need to have a broker and a result backend set.
::
CELERY_BROKER_URL = ...
CELERY_RESULT_BACKEND = ...
# some additional settings
CELERY_ACCEPT_CONTENT = ['pickle', 'json']
CELERY_CHORD_PROPAGATES = True
Rest-Framework
''''''''''''''
We use Rest-Framework for Angular frontend:
::
REST_FRAMEWORK = {
'PAGINATE_BY': 50,
'PAGINATE_BY_PARAM': 'page_size',
'MAX_PAGINATE_BY': 100,
}
Site URL
''''''''
Specify the base URL under which your site will be available. Defaults to: *http://localhost:8000*
Necessary for creating links to the site within the notification emails.
::
# base url to the site
PRICE_MONITOR_BASE_URL = 'https://....'
AWS and Product Advertising API credentials
'''''''''''''''''''''''''''''''''''''''''''
::
# your Amazon Web Services access key id
PRICE_MONITOR_AWS_ACCESS_KEY_ID = '...'
# your Amazon Web Services secret access key
PRICE_MONITOR_AWS_SECRET_ACCESS_KEY = '...'
# the region endpoint you want to use.
# Typically the country you'll run the price monitor in.
# possible values: CA, CN, DE, ES, FR, IT, JP, UK, US
PRICE_MONITOR_AMAZON_PRODUCT_API_REGION = '...'
# the assoc tag of the Amazon Product Advertising API
PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG = '...'
Amazon associates
'''''''''''''''''
As the links to Amazon will be affiliate links with your Amazon associate tag (see above), you have to set your name for the disclaimer
(see `https://partnernet.amazon.de/gp/associates/agreement <https://partnernet.amazon.de/gp/associates/agreement>`__).
::
# name of you/your site
PRICE_MONITOR_AMAZON_ASSOCIATE_NAME = 'name/sitename'
# Amazon site being used, choose from on of the following
'Amazon.co.uk'
'Local.Amazon.co.uk'
'Amazon.de'
'de.BuyVIP.com'
'Amazon.fr'
'Amazon.it'
'it.BuyVIP.com'
'Amazon.es'
'es.BuyVIP.com'
PRICE_MONITOR_AMAZON_ASSOCIATE_SITE = '<ONE FROM ABOVE>'
Images protocol and domain
''''''''''''''''''''''''''
::
# if to use the HTTPS URLs for Amazon images.
# if you're running the monitor on SSL, set this to True
# INFO:
# Product images are served directly from Amazon.
# This is a restriction when using the Amazon Product Advertising API
PRICE_MONITOR_IMAGES_USE_SSL = True
# domain to use for image serving.
# typically analog to the api region following the URL pattern
# https://images-<REGION>.ssl-images-amazon.com
PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN = 'https://images-eu.ssl-images-amazon.com'
Optional settings
^^^^^^^^^^^^^^^^^
The following settings can be adjusted but come with reasonable default
values.
Product synchronization
'''''''''''''''''''''''
::
# time after which products shall be refreshed
# Amazon only allows caching up to 24 hours, so the maximum value is 1440!
PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES = 720 # 12 hours
Notifications
'''''''''''''
To be able to send out the notification emails, set up a proper email
backend (see `Django
documentation <https://docs.djangoproject.com/en/1.5/topics/email/#topic-email-backends>`__).
::
# time after which to notify the user again about a price limit hit (in minutes)
PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES = 10080 # 7 days
# sender address of the notification email
PRICE_MONITOR_EMAIL_SENDER = 'noreply@localhost'
# currency name to use on notifications
PRICE_MONITOR_DEFAULT_CURRENCY = 'EUR'
# subject and body of the notification emails
gettext = lambda x: x
PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_SUBJECT = gettext(
'Price limit for %(product)s reached'
)
PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_BODY = gettext(
'The price limit of %(price_limit)0.2f %(currency)s has been reached for the '
'article "%(product_title)s" - the current price is %(price)0.2f %(currency)s.'
'\n\nPlease support our platform by using this '
'link for buying: %(link)s\n\n\nRegards,\nThe Team'
)
# name of the site in notifications
PRICE_MONITOR_SITENAME = 'Price Monitor'
Caching
'''''''
::
# key of cache (according to project config) to use for graphs
# None disables caching.
PRICE_MONITOR_GRAPH_CACHE_NAME = None
# prefix for cache key used for graphs
PRICE_MONITOR_GRAPH_CACHE_KEY_PREFIX = 'graph_'
Celery settings
~~~~~~~~~~~~~~~
To be able to run the required Celery tasks, Celery itself has to be set up. Please see the `Celery Documentation <http://docs.celeryproject.org/en/latest/index.html>`__ about how to setup the whole thing. You'll need a broker and a result backend configured.
Development setup with Docker
-----------------------------
The package comes with an easy to use Docker setup - you just need ``docker`` and ``docker-compose``.
Structure
~~~~~~~~~
There are 5 containers:
====== =======================================================================
db Postgres database
------ -----------------------------------------------------------------------
redis Celery broker
------ -----------------------------------------------------------------------
web a django project containing the ``django-amazon-price-monitor`` package
------ -----------------------------------------------------------------------
celery the celery for the django project
------ -----------------------------------------------------------------------
data container for mounted volumes
====== =======================================================================
The ``web`` and ``celery`` containers are using a docker image being set up under ``docker/web``.
Image: base
^^^^^^^^^^^
Basic image with all necessary system packages and pre-installed ``lxml`` and ``psycopg2``.
The image can be found on `Docker Hub <https://hub.docker.com/r/pricemonitor/base/>`__.
Image: web
^^^^^^^^^^
It comes with a Django project with login/logout view, that can be found under ``docker/web/project``.
The image derives from ``pricemonitor/base`` from above.
The directory structure within the container is the following (base dir: ``/srv/``):
::
root:/srv tree
├── logs [log files]
├── media [media files]
├── project [the django project]
├── static [static files]
└── pricemonitor [the pricemonitor package]
Starts via the start script ``docker/web/web_run.sh`` that does migrations and the starts the ``runserver``.
Image: celery
^^^^^^^^^^^^^
Basically the same as ``web``, but starts the Celery worker with beat.
If you want to develop anything involving tasks, see the `Usage <_docker-usage-override-settings>`__ section below.
Volumes
^^^^^^^
The containers mount several paths:
+--------------------------+----------------------------------+----------------------------------------------------+
| Folder in container | Folder on host | Information |
+==========================+==================================+====================================================+
| /var/lib/postgresql/data | <PROJECTROOT>/docker/postgres | * Postgres data directory |
| | | * Keeps the DB data even if container is removed |
+--------------------------+----------------------------------+----------------------------------------------------+
| /srv/logs | <PROJECTROOT>/docker/logs | Django logs (see project settings) |
+--------------------------+----------------------------------+----------------------------------------------------+
| /srv/media | <PROJECTROOT>/docker/media | Django media files |
+--------------------------+----------------------------------+----------------------------------------------------+
| /srv/project | <PROJECTROOT>/docker/web/project | * the Django project |
| | | * is copied on Dockerfile to get it up and running |
| | | * then mounted over (the copy is overwritten) |
+--------------------------+----------------------------------+----------------------------------------------------+
| /srv/pricemonitor | <PROJECTROOT> | * the ``django-amazon-price-monitor`` lib |
| | | * is copied on Dockerfile to get it up and running |
| | | * then mounted over (the copy is overwritten) |
+--------------------------+----------------------------------+----------------------------------------------------+
Usage
~~~~~
.. _docker-usage-override-settings:
Override settings
^^^^^^^^^^^^^^^^^
To override some settings as well as to set up the **required AWS settings** you can create a ``docker-compose.override.yml`` and fill with the specific values
(also see `docker-compose documentation <https://docs.docker.com/compose/extends/>`__).
Please see or adjust the ``docker\web\project\glue\settings.py`` for all settings that are read from the environment.
They can be overwritten.
A sample ``docker-compose.override.yml`` file could look like this:
::
version: '3'
services:
celery:
command: /bin/true
environment:
PRICE_MONITOR_AWS_ACCESS_KEY_ID: XXX
PRICE_MONITOR_AWS_SECRET_ACCESS_KEY: XXX
PRICE_MONITOR_AMAZON_PRODUCT_API_REGION: DE
PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG: XXX
PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES: 5
PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES: 60
It will avoid the automatic startup of celery (``command: /bin/true``) and set the required settings for AWS (in fact they are only needed in the celery
container). You can then manually start the container and execute celery which is quite useful if you develop anything that includes changes in the tasks and
thus requires the celery to be restarted (execute from the ``docker`` folder!):
::
alex@tyrion:~/projects/github/django-amazon-price-monitor/docker$ docker-compose run celery bash
Starting docker_data_1
# check environment variables
root@9d64bbd23e98:/srv/project# env
HOSTNAME=9d64bbd23e98
EMAIL_BACKEND=django.core.mail.backends.filebased.EmailBackend
POSTGRES_DB=pm_db
TERM=xterm
PYTHONUNBUFFERED=1
PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES=60
POSTGRES_PASSWORD=6i2vmzq5C6BuSf5k33A6tmMSHwKKv0Pu
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SECRET_KEY=Vceev7yWMtEQzHaTZX52
PWD=/srv/project
CELERY_BROKER_URL=redis://redis/1
C_FORCE_ROOT='True'
PRICE_MONITOR_AWS_SECRET_ACCESS_KEY=XXX
POSTGRES_USER=pm_user
SHLVL=1
HOME=/root
PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES=5
PRICE_MONITOR_AMAZON_PRODUCT_API_REGION=DE
PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG=XXX
DEBUG='True'
PRICE_MONITOR_AWS_ACCESS_KEY_ID=XXX
_=/usr/bin/env
# start celery (worker and beat) (can also execute /srv/celery_run.sh)
root@9d64bbd23e98:/srv/project# celery --beat -A glue worker
-------------- celery@9d64bbd23e98 v3.1.23 (Cipater)
---- **** -----
--- * *** * -- Linux-3.16.0-4-amd64-x86_64-with-debian-8.0
-- * - **** ---
- ** ---------- [config]
- ** ---------- .> app: glue:0x7fc6b5269e10
- ** ---------- .> transport: redis://redis:6379/1
- ** ---------- .> results: disabled://
- *** --- * --- .> concurrency: 8 (prefork)
-- ******* ----
--- ***** ----- [queues]
-------------- .> celery exchange=celery(direct) key=celery
[2016-03-20 10:02:26,776: WARNING/MainProcess] celery@9d64bbd23e98 ready.
Start/Stop/Build
^^^^^^^^^^^^^^^^
::
cd docker
# start
docker-compose up -d
# stop
docker-compose stop
# inspect
docker-compose logs -f
A fixture with a Django user ``admin`` and the password ``password`` is loaded automatically.
To build the images, use the `Makefile` from the root directory.
Templates
---------
As the fronted is done by Angular, there is only a single template with very limited possibilities to adjust, ``price_monitor/angular_index_view.html``. You
can extends the template and adjust the following blocks.
footer
~~~~~~
Is rendered on the very bottom of the page. You have to use Bootstrap compatible markup, e.g.:
::
{% block footer %}
<div class="row">
<div class="col-md-12">Additonal footer</div>
</div>
{% endblock %}
Management Commands
-------------------
price\_monitor\_batch\_create\_products
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A management command to batch create a number of products by providing
their ASIN:
::
python manage.py price_monitor_batch_create_products <ASIN1> <ASIN2> <ASIN3>
price\_monitor\_recreate\_product
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Recreates a product with the given asin. If product already exists, it
is deleted. *Only use in development!*
::
python manage.py price_monitor_recreate_product <ASIN>
price\_monitor\_search
~~~~~~~~~~~~~~~~~~~~~~
Searches for products at Amazon (not within the database) with the given
ASINs and prints out their details.
::
python manage.py price_monitor_search <ASIN1> <ASIN2> ...
Loggers
-------
price\_monitor
~~~~~~~~~~~~~~
The app uses the logger "price\_monitor" to log all error and info
messages that are not included within a dedicated other logger. Please
see the `Django logging
documentation <https://docs.djangoproject.com/en/1.6/topics/logging/>`__
for how to setup loggers.
price\_monitor.product\_advertising\_api
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Logger for everything related to the ProductAdvertisingAPI wrapper class
that accesses the Amazon Product Advertising API through bottlenose.
price\_monitor.utils
~~~~~~~~~~~~~~~~~~~~
Logger for the utils module.
Tests
-----
Coverage
~~~~~~~~
|codecov-graph|
Internals
---------
Model graph
~~~~~~~~~~~
.. figure:: https://github.com/ponyriders/django-amazon-price-monitor/raw/master/models.png
:alt: Model Graph
Product advertising api synchronization
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Task workflow
^^^^^^^^^^^^^
.. figure:: https://raw.githubusercontent.com/ponyriders/django-amazon-price-monitor/master/docs/price_monitor.product_advertising_api.tasks.png
:alt: Image of Product advertising api synchronization workflow
Image of Product advertising api synchronization workflow
.. |Build Status| image:: https://travis-ci.org/ponyriders/django-amazon-price-monitor.svg?branch=master
:target: https://travis-ci.org/ponyriders/django-amazon-price-monitor
.. |codecov.io| image:: http://codecov.io/github/ponyriders/django-amazon-price-monitor/coverage.svg?branch=master
:target: http://codecov.io/github/ponyriders/django-amazon-price-monitor?branch=master
.. |codecov-graph| image:: http://codecov.io/github/ponyriders/django-amazon-price-monitor/branch.svg?branch=master
.. |Requirements Status| image:: https://requires.io/github/ponyriders/django-amazon-price-monitor/requirements.svg?branch=master
:target: https://requires.io/github/ponyriders/django-amazon-price-monitor/requirements/?branch=master
.. |Stories in Ready| image:: https://badge.waffle.io/ponyriders/django-amazon-price-monitor.png?label=ready&title=Ready
:target: https://waffle.io/ponyriders/django-amazon-price-monitor
.. |Landscape| image:: https://landscape.io/github/ponyriders/django-amazon-price-monitor/master/landscape.svg?style=flat
:target: https://landscape.io/github/ponyriders/django-amazon-price-monitor/master
:alt: Code Health
================================================
FILE: docker/.gitignore
================================================
docker-compose.override.yml
logs
media
postgres
web/project/celerybeat-schedule.db
================================================
FILE: docker/base/Dockerfile
================================================
# basic setup
FROM philcryer/min-jessie
MAINTAINER Alexander Herrmann <darignac@gmail.com>
# install basic packages: lxml dependencies, python3 and git
# see recommendation https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#apt-get
RUN apt-get update && apt-get install -y \
git \
libffi-dev \
libjpeg-dev \
libpq-dev \
libxml2-dev \
libxslt1-dev \
postgresql-client-9.4 \
python3-cairo \
python3-minimal \
python3-pip \
&& rm -rf /tmp/* /var/tmp/* \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# install lxml and psycopg2 - they take the most amount of time compiling
RUN pip3 install lxml psycopg2 setuptools
================================================
FILE: docker/compose.env
================================================
PYTHONUNBUFFERED=1
POSTGRES_USER=pm_user
POSTGRES_DB=pm_db
POSTGRES_PASSWORD=6i2vmzq5C6BuSf5k33A6tmMSHwKKv0Pu
DEBUG='True'
SECRET_KEY=Vceev7yWMtEQzHaTZX52
EMAIL_BACKEND=django.core.mail.backends.filebased.EmailBackend
C_FORCE_ROOT='True'
CELERY_BROKER_URL=redis://redis/1
================================================
FILE: docker/docker-compose.yml
================================================
version: '3'
services:
# database container
db:
image: postgres:9
env_file: compose.env
volumes:
- ./postgres:/var/lib/postgresql/data
# redis for celery
redis:
image: redis:3
# web container with Django project
web:
build: ./web
image: pricemonitor/web
depends_on:
- db
ports:
- "8000:8000"
env_file: compose.env
command: /srv/web_run.sh
volumes:
- ./logs:/srv/logs
- ./media:/srv/media
- ./web/project:/srv/project
- ../:/srv/pricemonitor
# celery container
celery:
build: ./web
image: pricemonitor/web
depends_on:
- redis
env_file: compose.env
command: /srv/celery_run.sh
volumes:
- ./web/project:/srv/project
- ../:/srv/pricemonitor
================================================
FILE: docker/web/Dockerfile
================================================
# basic setup, use base image of treasury project
FROM pricemonitor/base:latest
MAINTAINER Alexander Herrmann <darignac@gmail.com>
# django setup, create default folder and volumes
WORKDIR /srv/
RUN mkdir static
VOLUME ["/srv/media", "/srv/logs", "/srv/pricemonitor", "/srv/project"]
# copy the django project files
COPY project /srv/project
COPY web_run.sh /srv/web_run.sh
COPY celery_run.sh /srv/celery_run.sh
# install python dependencies for the django project
RUN pip3 install -r /srv/project/requirements.pip
# copy the treasury package and install - will be mounted later through data container (and thus overwritten with the host files)
ADD django-amazon-price-monitor /srv/pricemonitor
RUN pip3 install -e /srv/pricemonitor
# ports
EXPOSE 8000
# entrypoint
WORKDIR /srv/project
================================================
FILE: docker/web/celery_run.sh
================================================
#!/bin/sh
# wait for redis
sleep 5
celery --beat -A glue worker
================================================
FILE: docker/web/django-amazon-price-monitor/price_monitor/__init__.py
================================================
"""Dummy init module for the price_monitor package. It will be overwritten when docker mounts the real package."""
def get_version():
"""
Returns DEV as version. Just a placeholder while building the web docker image, will be overwritten by mounted docker volume.
:return: the version identifier
"""
return 'DEV'
================================================
FILE: docker/web/django-amazon-price-monitor/setup.py
================================================
#!/usr/bin/env python
"""Setup file for the django-amazon-price-monitor package."""
try:
from setuptools import setup
except ImportError:
from distutils.core import setup
readme = ""
history = ""
setup(
name='django-amazon-price-monitor',
version=__import__('price_monitor').get_version().replace(' ', '-'),
description='Monitors prices of Amazon products via Product Advertising API',
long_description=readme + '\n\n' + history,
author='Alexander Herrmann, Martin Mrose',
author_email='django-amazon-price-monitor@googlegroups.com',
url='https://github.com/ponyriders/django-amazon-price-monitor',
packages=[
'price_monitor'
],
include_package_data=True,
install_requires=[
# main dependencies
'Django>=1.8,<2',
# for product advertising api
'beautifulsoup4<=4.6',
'bottlenose>=0.6.2,<1.2',
'celery>=4,<4.1',
'python-dateutil>=2.5.1,<2.7',
'kombu>=4.1.0,<4.2',
# for pm api
'djangorestframework>=3.3,<3.7',
# for graphs
'pygal>=2.0.7,<2.5',
'lxml>=4,<4.1',
# pygal png output
'CairoSVG>=2,<2.1',
'tinycss>=0.4,<0.5',
'cssselect>=1.0.1,<1.1',
],
license='MIT',
zip_safe=False,
)
================================================
FILE: docker/web/project/glue/__init__.py
================================================
"""Glue project init"""
from __future__ import absolute_import
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app # noqa
================================================
FILE: docker/web/project/glue/celery.py
================================================
"""Celery setup for the glue project."""
from __future__ import absolute_import
import os
from celery import Celery
from django.conf import settings
# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'glue.settings')
app = Celery('glue')
# Using a string here means the worker will not have to
# pickle the object when using Windows.
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
================================================
FILE: docker/web/project/glue/settings.py
================================================
"""
Django settings for glue project.
Generated by 'django-admin startproject' using Django 1.8.2.
For more information on this file, see
https://docs.djangoproject.com/en/1.8/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.8/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('SECRET_KEY')
DEBUG = os.environ.get('DEBUG', False)
ALLOWED_HOSTS = ['127.0.0.1', '0.0.0.0', ]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'glue_auth',
'rest_framework',
'price_monitor',
'price_monitor.product_advertising_api',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'glue.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'glue.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.8/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': os.environ.get('POSTGRES_DB'),
'USER': os.environ.get('POSTGRES_USER'),
'PASSWORD': os.environ.get('POSTGRES_PASSWORD'),
'HOST': 'db',
'PORT': '5432',
}
}
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = '/srv/static/'
# Logging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '%(levelname)s %(asctime)s %(filename)s %(lineno)d %(message)s'
},
'simple': {
'format': '%(levelname)s %(message)s'
},
},
'handlers': {
'file_error': {
'level': 'ERROR',
'class': 'logging.FileHandler',
'filename': os.path.join(BASE_DIR, '..', 'logs', 'error.log'),
'formatter': 'verbose',
},
'price_monitor': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': os.path.join(BASE_DIR, '..', 'logs', 'price_monitor.log'),
'formatter': 'verbose',
},
'price_monitor.product_advertising_api': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': os.path.join(BASE_DIR, '..', 'logs', 'price_monitor.product_advertising_api.log'),
'formatter': 'verbose',
},
'price_monitor.tasks': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': os.path.join(BASE_DIR, '..', 'logs', 'price_monitor.tasks.log'),
'formatter': 'verbose',
},
'price_monitor.utils': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': os.path.join(BASE_DIR, '..', 'logs', 'price_monitor.utils.log'),
'formatter': 'verbose',
},
'mail_admins': {
'level': 'ERROR',
'class': 'django.utils.log.AdminEmailHandler',
'include_html': True,
},
},
'loggers': {
'django.request': {
'handlers': ['file_error', 'mail_admins'],
'level': 'ERROR',
'propagate': True,
},
'price_monitor': {
'handlers': ['price_monitor'],
'level': 'INFO',
'propagate': True,
},
'price_monitor.product_advertising_api': {
'handlers': ['price_monitor.product_advertising_api'],
'level': 'INFO',
'propagate': True,
},
'price_monitor.tasks': {
'handlers': ['price_monitor.tasks'],
'level': 'INFO',
'propagate': True,
},
'price_monitor.utils': {
'handlers': ['price_monitor.utils'],
'level': 'INFO',
'propagate': True,
},
},
}
# caching
# CACHES = {
# 'default': {
# 'BACKEND': 'redis_cache.RedisCache',
# 'LOCATION': 'redis',
# 'OPTIONS': {
# 'DB': 0,
# 'PARSER_CLASS': 'redis.connection.HiredisParser',
# 'CONNECTION_POOL_CLASS': 'redis.BlockingConnectionPool',
# 'CONNECTION_POOL_CLASS_KWARGS': {
# 'max_connections': 50,
# 'timeout': 20,
# }
# },
# },
# }
# CACHE_MIDDLEWARE_KEY_PREFIX = 'pm_glue'
# glue login
LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/login/'
# E-Mail
EMAIL_BACKEND = os.environ.get('EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend') # smtp is the Django default
if EMAIL_BACKEND == 'django.core.mail.backends.smtp.EmailBackend':
EMAIL_HOST = os.environ.get('EMAIL_HOST')
EMAIL_PORT = os.environ.get('EMAIL_PORT')
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD')
EMAIL_USE_TSL = os.environ.get('EMAIL_USE_TSL', True)
elif EMAIL_BACKEND == 'django.core.mail.backends.filebased.EmailBackend':
EMAIL_FILE_PATH = os.path.join(BASE_DIR, '..', 'logs', 'emails.out')
# Celery
CELERY_ACCEPT_CONTENT = ['pickle', 'json']
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL')
CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', '')
CELERY_CHORD_PROPAGATES = True
# redis specific, see http://celery.readthedocs.org/en/latest/getting-started/brokers/redis.html#caveats
CELERY_BROKER_TRANSPORT_OPTIONS = {
'fanout_prefix': True,
'fanout_patterns': True,
}
# price_monitor
PRICE_MONITOR_BASE_URL = os.environ.get('PRICE_MONITOR_BASE_URL', 'http://0.0.0.0:8000')
PRICE_MONITOR_AWS_ACCESS_KEY_ID = os.environ.get('PRICE_MONITOR_AWS_ACCESS_KEY_ID', '')
PRICE_MONITOR_AWS_SECRET_ACCESS_KEY = os.environ.get('PRICE_MONITOR_AWS_SECRET_ACCESS_KEY', '')
PRICE_MONITOR_AMAZON_PRODUCT_API_REGION = os.environ.get('PRICE_MONITOR_AMAZON_PRODUCT_API_REGION', 'DE')
PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG = os.environ.get('PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG', '')
PRICE_MONITOR_AMAZON_ASSOCIATE_NAME = 'John Doe'
PRICE_MONITOR_AMAZON_ASSOCIATE_SITE = 'Amazon.de'
PRICE_MONITOR_EMAIL_SENDER = 'Amazon Pricemonitor <pm@localhost>'
PRICE_MONITOR_SITENAME = 'Pricemonitor Site'
# refresh product after 1 hours
PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES = os.environ.get('PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES', 60)
# time after when to notify about a subscription again
PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES = os.environ.get('PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES', 24 * 3 * 60)
REST_FRAMEWORK = {
'PAGINATE_BY': 50,
'PAGINATE_BY_PARAM': 'page_size', # Allow client to override, using `?page_size=xxx`.
'MAX_PAGINATE_BY': 100 # Maximum limit allowed when using `?page_size=xxx`.
}
================================================
FILE: docker/web/project/glue/urls.py
================================================
"""URL definitions for the glue project."""
from django.conf.urls import include, url
from django.contrib import admin
admin.autodiscover()
urlpatterns = [
url(r'^admin/', admin.site.urls),
url(r'^', include('glue_auth.urls')),
url(r'^', include('price_monitor.urls')),
]
================================================
FILE: docker/web/project/glue/wsgi.py
================================================
"""
WSGI config for glue project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
# FIXME this is not production ready
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "glue.settings")
application = get_wsgi_application()
================================================
FILE: docker/web/project/glue_auth/__init__.py
================================================
================================================
FILE: docker/web/project/glue_auth/fixtures/admin.json
================================================
[
{
"model": "auth.user",
"pk": 1,
"fields": {
"password": "pbkdf2_sha256$24000$A7AExuKNKQp3$I4oqUrkVc6LVIZSv2f8DIbjWTSoD1entAJDHjOMV5OI=",
"last_login": null,
"is_superuser": true,
"username": "admin",
"first_name": "",
"last_name": "",
"email": "admin@localhost",
"is_staff": true,
"is_active": true,
"date_joined": "2016-03-19T13:16:19.168Z",
"groups": [],
"user_permissions": []
}
}
]
================================================
FILE: docker/web/project/glue_auth/models.py
================================================
================================================
FILE: docker/web/project/glue_auth/templates/glue_auth/base.html
================================================
<!DOCTYPE html>{% load static %}
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>{% block pagetitle %}Pricemonitor Site{% endblock %}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
{% block css_links %}{% endblock %}
{% block css_inline %}{% endblock %}
{% block js_links %}{% endblock %}
{% block js_inline %}{% endblock %}
</head>
<body>
{% block content %}Intentionally blank page.{% endblock %}
</body>
</html>
================================================
FILE: docker/web/project/glue_auth/templates/glue_auth/login.html
================================================
{% extends 'glue_auth/base.html' %}
{% block content %}
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}
<form method="post" action="{% url 'glue_auth:login' %}">
{% csrf_token %}
<table>
<tr>
<td>{{ form.username.label_tag }}</td>
<td>{{ form.username }}</td>
</tr>
<tr>
<td>{{ form.password.label_tag }}</td>
<td>{{ form.password }}</td>
</tr>
</table>
<input type="submit" value="login" />
<input type="hidden" name="next" value="{{ next }}" />
</form>
{% endblock %}
================================================
FILE: docker/web/project/glue_auth/templates/price_monitor/angular_index_view.html
================================================
{% extends "price_monitor/angular_index_view.html" %}
{% block footer %}
<div class="row">
<div class="col-md-12"><em>Template-Block: footer</em></div>
</div>
{% endblock %}
================================================
FILE: docker/web/project/glue_auth/urls.py
================================================
"""URL definitions for the glue_auth module."""
from django.conf.urls import url
from django.contrib.auth.views import login, logout_then_login
app_name = 'glue_auth'
urlpatterns = [
url(r'^login/$', login, {'template_name': 'glue_auth/login.html'}, name='login'),
url(r'^logout/$', logout_then_login, name='logout'),
]
================================================
FILE: docker/web/project/manage.py
================================================
#!/usr/bin/env python
"""Main Django entry point."""
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "glue.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
================================================
FILE: docker/web/project/requirements.pip
================================================
Django<2
dj-database-url
psycopg2>=2.5.4
celery>=4,<5
django-redis-cache>=1.5.4
hiredis<0.3
Pillow<6
================================================
FILE: docker/web/web_run.sh
================================================
#!/bin/sh
# wait for postgres
sleep 5
cd /srv/project/
python3 manage.py migrate
python3 manage.py loaddata admin
python3 manage.py runserver 0.0.0.0:8000
================================================
FILE: docs/price_monitor.product_advertising_api.tasks.activity.violet.html
================================================
<HTML>
<HEAD>
<META name="description"
content="Violet UML Editor cross format document" />
<META name="keywords" content="Violet, UML" />
<META charset="UTF-8" />
<SCRIPT type="text/javascript">
function switchVisibility() {
var obj = document.getElementById("content");
obj.style.display = (obj.style.display == "block") ? "none" : "block";
}
</SCRIPT>
</HEAD>
<BODY>
This file was generated with Violet UML Editor 2.1.0.
( <A href=# onclick="switchVisibility()">View Source</A> / <A href="http://sourceforge.net/projects/violet/files/violetumleditor/" target="_blank">Download Violet</A> )
<BR />
<BR />
<SCRIPT id="content" type="text/xml"><![CDATA[<ActivityDiagramGraph id="1">
<nodes id="2">
<ScenarioStartNode id="3">
<children id="4"/>
<location class="Point2D.Double" id="5" x="150.0" y="20.0"/>
<id id="6" value="596b93d4-16df-4581-b555-c7de74936216"/>
<revision>1</revision>
<backgroundColor id="7">
<red>255</red>
<green>255</green>
<blue>255</blue>
<alpha>255</alpha>
</backgroundColor>
<borderColor id="8">
<red>0</red>
<green>0</green>
<blue>0</blue>
<alpha>255</alpha>
</borderColor>
<textColor reference="8"/>
</ScenarioStartNode>
<ActivityNode id="9">
<children id="10"/>
<location class="Point2D.Double" id="11" x="20.0" y="100.0"/>
<id id="12" value="57d658da-2004-40b0-80dd-037c25c1db17"/>
<revision>1</revision>
<backgroundColor reference="7"/>
<borderColor reference="8"/>
<textColor reference="8"/>
<name id="13" justification="1" size="4" underlined="false">
<text>check if FindProductsToSynchronizeTask is already scheduled</text>
</name>
</ActivityNode>
<DecisionNode id="14">
<children id="15"/>
<location class="Point2D.Double" id="16" x="180.0" y="210.0"/>
<id id="17" value="72132dd4-b1e0-4caa-92c9-bafec679ba31"/>
<revision>1</revision>
<backgroundColor reference="7"/>
<borderColor reference="8"/>
<textColor reference="8"/>
<condition id="18" justification="1" size="4" underlined="false">
<text></text>
</condition>
</DecisionNode>
<ScenarioEndNode id="19">
<children id="20"/>
<location class="Point2D.Double" id="21" x="590.0" y="720.0"/>
<id id="22" value="03341895-bacf-48a0-8219-145598b87c40"/>
<revision>1</revision>
<backgroundColor reference="7"/>
<borderColor reference="8"/>
<textColor reference="8"/>
</ScenarioEndNode>
<ActivityNode id="23">
<children id="24"/>
<location class="Point2D.Double" id="25" x="470.0" y="260.0"/>
<id id="26" value="7fd6c068-b6fd-4571-ae5e-cd1cdf2a79d8"/>
<revision>1</revision>
<backgroundColor reference="7"/>
<borderColor reference="8"/>
<textColor reference="8"/>
<name id="27" justification="1" size="4" underlined="false">
<text>query for products to update</text>
</name>
</ActivityNode>
<ActivityNode id="28">
<children id="29"/>
<location class="Point2D.Double" id="30" x="600.0" y="400.0"/>
<id id="31" value="23238468-9241-4ec6-baf6-a63dbc8d93d3"/>
<revision>1</revision>
<backgroundColor reference="7"/>
<borderColor reference="8"/>
<textColor reference="8"/>
<name id="32" justification="1" size="4" underlined="false">
<text>for each 10 products execute SynchronizeProductsTask</text>
</name>
</ActivityNode>
<DecisionNode id="33">
<children id="34"/>
<location class="Point2D.Double" id="35" x="530.0" y="350.0"/>
<id id="36" value="b9c5b980-201e-4993-abdf-131180c5994c"/>
<revision>1</revision>
<backgroundColor reference="7"/>
<borderColor reference="8"/>
<textColor reference="8"/>
<condition id="37" justification="1" size="4" underlined="false">
<text></text>
</condition>
</DecisionNode>
<ActivityNode id="38">
<children id="39"/>
<location class="Point2D.Double" id="40" x="640.0" y="510.0"/>
<id id="41" value="6b01331c-b058-4499-b09a-874eaa007643"/>
<revision>1</revision>
<backgroundColor reference="7"/>
<borderColor reference="8"/>
<textColor reference="8"/>
<name id="42" justification="1" size="4" underlined="false">
<text>schedule FindProductsToSynchronizeTask</text>
</name>
</ActivityNode>
<ActivityNode id="43">
<children id="44"/>
<location class="Point2D.Double" id="45" x="230.0" y="400.0"/>
<id id="46" value="63226117-a9d8-412b-a5b2-e6a4ebd85fc5"/>
<revision>1</revision>
<backgroundColor reference="7"/>
<borderColor reference="8"/>
<textColor reference="8"/>
<name id="47" justification="1" size="4" underlined="false">
<text>find date for next update cycle</text>
</name>
</ActivityNode>
<ActivityNode id="48">
<children id="49"/>
<location class="Point2D.Double" id="50" x="110.0" y="510.0"/>
<id id="51" value="c0026e8d-0e61-4834-af9c-69298ef3aa94"/>
<revision>1</revision>
<backgroundColor reference="7"/>
<borderColor reference="8"/>
<textColor reference="8"/>
<name id="52" justification="1" size="4" underlined="false">
<text>schedule FindProductsToSynchronizeTask with next cycle date as eta</text>
</name>
</ActivityNode>
</nodes>
<edges id="53">
<ActivityTransitionEdge id="54">
<start class="ScenarioStartNode" reference="3"/>
<end class="ActivityNode" reference="9"/>
<startLocation class="Point2D.Double" id="55" x="10.0" y="10.0"/>
<endLocation class="Point2D.Double" id="56" x="170.0" y="10.0"/>
<transitionPoints id="57">
<Point2D.Double id="58" x="220.0" y="30.0"/>
</transitionPoints>
<id id="59" value="ae418b6a-2954-4425-8f6a-3a41d1c0515f"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="FREE"/>
<startLabel></startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
<ActivityTransitionEdge id="60">
<start class="ActivityNode" reference="9"/>
<end class="DecisionNode" reference="14"/>
<startLocation class="Point2D.Double" id="61" x="160.0" y="30.0"/>
<endLocation class="Point2D.Double" id="62" x="40.0" y="10.0"/>
<transitionPoints id="63"/>
<id id="64" value="889b8012-e61b-4db8-b8be-4a067b2f724e"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="AUTO"/>
<startLabel></startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
<ActivityTransitionEdge id="65">
<start class="DecisionNode" reference="14"/>
<end class="ActivityNode" reference="23"/>
<startLocation class="Point2D.Double" id="66" x="60.0" y="20.0"/>
<endLocation class="Point2D.Double" id="67" x="50.0" y="40.0"/>
<transitionPoints class="Point2D.Double-array" id="68"/>
<id id="69" value="0c21ae49-d53a-45c1-ae12-1eae41fcad68"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="HV"/>
<startLabel>no</startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
<ActivityTransitionEdge id="70">
<start class="ActivityNode" reference="23"/>
<end class="DecisionNode" reference="33"/>
<startLocation class="Point2D.Double" id="71" x="170.0" y="40.0"/>
<endLocation class="Point2D.Double" id="72" x="40.0" y="10.0"/>
<transitionPoints id="73"/>
<id id="74" value="8583a4f0-df19-4f25-a956-d89bc9cfd671"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="AUTO"/>
<startLabel></startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
<ActivityTransitionEdge id="75">
<start class="DecisionNode" reference="33"/>
<end class="ActivityNode" reference="28"/>
<startLocation class="Point2D.Double" id="76" x="50.0" y="20.0"/>
<endLocation class="Point2D.Double" id="77" x="50.0" y="10.0"/>
<transitionPoints id="78"/>
<id id="79" value="1133f62d-c35d-4102-b14a-f2768df03359"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="HV"/>
<startLabel>products available</startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
<ActivityTransitionEdge id="80">
<start class="ActivityNode" reference="28"/>
<end class="ActivityNode" reference="38"/>
<startLocation class="Point2D.Double" id="81" x="160.0" y="50.0"/>
<endLocation class="Point2D.Double" id="82" x="130.0" y="40.0"/>
<transitionPoints id="83"/>
<id id="84" value="1cd5b5b2-4af8-46d4-bdbc-d3cdcec06aa3"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="AUTO"/>
<startLabel></startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
<ActivityTransitionEdge id="85">
<start class="ActivityNode" reference="38"/>
<end class="ScenarioEndNode" reference="19"/>
<startLocation class="Point2D.Double" id="86" x="110.0" y="40.0"/>
<endLocation class="Point2D.Double" id="87" x="10.0" y="10.0"/>
<transitionPoints id="88">
<Point2D.Double id="89" x="780.0" y="730.0"/>
</transitionPoints>
<id id="90" value="22f988b3-95be-426c-a69e-c2ce47aec62a"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="FREE"/>
<startLabel></startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
<ActivityTransitionEdge id="91">
<start class="DecisionNode" reference="33"/>
<end class="ActivityNode" reference="43"/>
<startLocation class="Point2D.Double" id="92" x="50.0" y="20.0"/>
<endLocation class="Point2D.Double" id="93" x="190.0" y="20.0"/>
<transitionPoints id="94"/>
<id id="95" value="b7d5e7c1-c3d6-436e-b035-9bfe8ebb556f"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="HV"/>
<startLabel>no products available</startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
<ActivityTransitionEdge id="96">
<start class="ActivityNode" reference="43"/>
<end class="ActivityNode" reference="48"/>
<startLocation class="Point2D.Double" id="97" x="60.0" y="50.0"/>
<endLocation class="Point2D.Double" id="98" x="170.0" y="10.0"/>
<transitionPoints id="99"/>
<id id="100" value="e6cce7eb-e720-4f94-9c22-6ecb1ab60981"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="AUTO"/>
<startLabel></startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
<ActivityTransitionEdge id="101">
<start class="ActivityNode" reference="48"/>
<end class="ScenarioEndNode" reference="19"/>
<startLocation class="Point2D.Double" id="102" x="100.0" y="40.0"/>
<endLocation class="Point2D.Double" id="103" x="10.0" y="10.0"/>
<transitionPoints id="104">
<Point2D.Double id="105" x="600.0" y="540.0"/>
</transitionPoints>
<id id="106" value="0725e3f4-7140-4799-8073-5e3b6d1e916f"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="FREE"/>
<startLabel></startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
<ActivityTransitionEdge id="107">
<start class="DecisionNode" reference="14"/>
<end class="ScenarioEndNode" reference="19"/>
<startLocation class="Point2D.Double" id="108" x="50.0" y="30.0"/>
<endLocation class="Point2D.Double" id="109" x="10.0" y="10.0"/>
<transitionPoints id="110">
<Point2D.Double id="111" x="70.0" y="230.0"/>
<Point2D.Double id="112" x="70.0" y="730.0"/>
</transitionPoints>
<id id="113" value="d4d2be9c-3e06-47a8-876b-edc360eefe33"/>
<revision>1</revision>
<lineStyle name="SOLID"/>
<startArrowHead name="NONE"/>
<bentStyle name="FREE"/>
<startLabel>yes</startLabel>
<middleLabel></middleLabel>
<endLabel></endLabel>
</ActivityTransitionEdge>
</edges>
</ActivityDiagramGraph>]]></SCRIPT>
<BR />
<BR />
<IMG alt="embedded diagram image" src="data:image/png;base64,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" />
</BODY>
</HTML>
================================================
FILE: hooks/pre-commit
================================================
#!/bin/sh
flake8 price_monitor --ignore=E501,E128 --exclude=migrations
================================================
FILE: price_monitor/__init__.py
================================================
"""
django-amazon-price-monitor monitors prices of Amazon products.
"""
__version_info__ = {
'major': 0,
'minor': 7,
'micro': 0,
'releaselevel': 'final',
'serial': 0,
}
def get_version(short=False):
assert __version_info__['releaselevel'] in ('alpha', 'beta', 'final')
version = ["{major:d}.{minor:d}".format(**__version_info__), ]
if __version_info__['micro']:
version.append(".{micro:d}".format(**__version_info__))
if __version_info__['releaselevel'] != 'final' and not short:
version.append('{0!s}{1:d}'.format(__version_info__['releaselevel'][0], __version_info__['serial']))
return ''.join(version)
__version__ = get_version()
================================================
FILE: price_monitor/admin.py
================================================
"""AdminSite definitions"""
from django.contrib import admin
from django.utils.translation import ugettext_lazy
from price_monitor.models import (
EmailNotification,
Price,
Product,
Subscription,
)
class PriceAdmin(admin.ModelAdmin):
"""Admin for the model Price"""
list_display = ('date_seen', 'value', 'currency', )
list_filter = ('product', )
class ProductAdmin(admin.ModelAdmin):
"""Admin for the model Product"""
list_display = ('asin', 'title', 'artist', 'audience_rating', 'status', 'date_updated', 'date_last_synced', )
list_filter = ('status', 'audience_rating', )
search_fields = ('asin', )
readonly_fields = ('current_price', 'highest_price', 'lowest_price',)
actions = ['reset_to_created', 'resynchronize', ]
def reset_to_created(self, request, queryset): # pylint:disable=unused-argument
"""
Resets the status of the product back to created.
:param request: sent request
:param queryset: queryset containing the products
"""
queryset.update(status=0)
reset_to_created.short_description = ugettext_lazy('Reset to status "Created".')
def resynchronize(self, request, queryset): # pylint:disable=unused-argument
"""
Synchronizes the sent products with the product advertising api.
:param request: sent request
:param queryset: queryset containing the products
"""
from price_monitor.product_advertising_api.tasks import SynchronizeProductsTask
for product in queryset:
SynchronizeProductsTask.delay([product.asin])
resynchronize.short_description = ugettext_lazy('Resynchronize with API')
class SubscriptionAdmin(admin.ModelAdmin):
"""Admin for the model Subscription"""
list_display = ('product', 'price_limit', 'owner', 'date_last_notification', 'get_email_address', 'public_id',)
list_filter = ('owner__username', 'price_limit', )
class EmailNotificationAdmin(admin.ModelAdmin):
"""Admin for the model EmailNotification"""
list_display = ('email', 'owner', 'public_id',)
admin.site.register(Price, PriceAdmin)
admin.site.register(Product, ProductAdmin)
admin.site.register(Subscription, SubscriptionAdmin)
admin.site.register(EmailNotification, EmailNotificationAdmin)
================================================
FILE: price_monitor/api/__init__.py
================================================
================================================
FILE: price_monitor/api/renderers/PriceChartPNGRenderer.py
================================================
"""Module for rendering price charts as PNG"""
import dateutil.parser
import hashlib
from ... import app_settings
from django.core.cache import caches
from django.core.cache.backends.base import InvalidCacheBackendError
from pygal import DateTimeLine
from pygal.style import RedBlueStyle
from rest_framework.renderers import BaseRenderer
from tempfile import TemporaryFile
def bool_helper(x):
"""
Returns True if the value is something that can be mapped to a boolean value.
:param x: the value to check
:return: the mapped boolean value or False if not mappable
"""
return x in [1, '1', 'true', 'True']
class PriceChartPNGRenderer(BaseRenderer):
"""A renderer to render charts as PNG for prices"""
media_type = 'image/png'
format = 'png'
charset = None
render_style = 'binary'
# TODO: documentation
allowed_chart_url_args = {
'height': lambda x: int(x), # pylint:disable=unnecessary-lambda
'width': lambda x: int(x), # pylint:disable=unnecessary-lambda
'margin': lambda x: int(x), # pylint:disable=unnecessary-lambda
'spacing': lambda x: int(x), # pylint:disable=unnecessary-lambda
'show_dots': bool_helper,
'show_legend': bool_helper,
'show_x_labels': bool_helper,
'show_y_labels': bool_helper,
'show_minor_y_labels': bool_helper,
'y_labels_major_count': lambda x: int(x), # pylint:disable=unnecessary-lambda
}
allowed_style_url_args = {
'no_data_font_size': lambda x: int(x), # pylint:disable=unnecessary-lambda
}
def render(self, data, accepted_media_type=None, renderer_context=None): # pylint:disable=unused-argument
"""Renders `data` into serialized XML."""
# first get the cache to use or None
try:
cache = caches[app_settings.PRICE_MONITOR_GRAPH_CACHE_NAME]
except InvalidCacheBackendError:
cache = None
# sanitize arguments
sanitized_args = self.sanitize_allowed_args(renderer_context['request']) if 'request' in renderer_context else {}
# generate cache key
cache_key = self.create_cache_key(data, sanitized_args)
# only read from cache if there is any
content = cache.get(cache_key) if cache is not None else None
if content is None:
# create graph instance
graph = self.create_graph(data, sanitized_args)
# write graph to temporary file
with TemporaryFile() as file_:
graph.render_to_png(file_)
# only write to cache if there is any
if cache is not None:
# seek back to start
file_.seek(0)
cache.set(cache_key, file_.read())
# and back to start again
file_.seek(0)
# return the content
return file_.read()
else:
# return the cache content
return content
def sanitize_allowed_args(self, request):
"""Checks url arguments by using the sanitation methods given in self.allowed_*_url_args"""
sanitized_args = {}
if request.method == 'POST':
args = request.POST
elif request.method == 'GET':
args = request.GET
else:
return sanitized_args
for arg, sanitizer in self.allowed_chart_url_args.items() ^ self.allowed_style_url_args.items():
if arg in args:
try:
sanitized_args[arg] = sanitizer(args[arg])
except ValueError:
# sanitation gone wrong, so pass
continue
return sanitized_args
def create_cache_key(self, data, args):
"""Creates a cache key based on rendering data"""
hash_data = str(data).encode('utf-8')
hash_data += str(args).encode('utf-8')
return app_settings.PRICE_MONITOR_GRAPH_CACHE_KEY_PREFIX + hashlib.md5(hash_data).hexdigest()
def create_graph(self, data, args):
"""Creates the graph based on rendering data"""
style_arguments = {}
for arg in self.allowed_style_url_args.keys():
if arg in args:
style_arguments.update({arg: args[arg]})
line_chart_arguments = {
'style': RedBlueStyle(**style_arguments),
'x_label_rotation': 25,
'x_value_formatter': lambda dt: dt.strftime('%y-%m-%d %H:%M'),
}
for arg in self.allowed_chart_url_args.keys():
if arg in args:
line_chart_arguments.update({arg: args[arg]})
line_chart = DateTimeLine(**line_chart_arguments)
if data:
values = [(dateutil.parser.parse(price['date_seen']), price['value']) for price in data]
line_chart.add(data[0]['currency'], values)
return line_chart
================================================
FILE: price_monitor/api/renderers/__init__.py
================================================
================================================
FILE: price_monitor/api/serializers/EmailNotificationSerializer.py
================================================
"""Serializer for EmailNotification model"""
from ...models import EmailNotification
from rest_framework import serializers
class EmailNotificationSerializer(serializers.ModelSerializer):
"""Serializes EmailNotification objects. Just renders public_id as id and the email address"""
owner = serializers.HiddenField(
default=serializers.CurrentUserDefault()
)
class Meta(object):
"""Some model meta"""
model = EmailNotification
fields = ('owner', 'email',)
================================================
FILE: price_monitor/api/serializers/PriceSerializer.py
================================================
"""Serializer for Price model"""
from ...models import Price
from rest_framework import serializers
class PriceSerializer(serializers.ModelSerializer):
"""Serializes prices by showing currency, value and date seen"""
class Meta(object):
"""Some model meta"""
model = Price
fields = (
'value',
'currency',
'date_seen',
)
================================================
FILE: price_monitor/api/serializers/ProductSerializer.py
================================================
"""Serializer for Product model"""
from .SubscriptionSerializer import SubscriptionSerializer
from ...models import EmailNotification, Product, Subscription
from django.db import transaction
from rest_framework import serializers
class ProductSerializer(serializers.ModelSerializer):
"""
Product serializer. Serializes all fields needed for frontend and id from asin.
Also sets all fields but asin to read only
"""
asin = serializers.CharField(max_length=100)
# for these three values get_{{ value name }} is the default, but DRF prohibits setting the default value ...
current_price = serializers.SerializerMethodField()
highest_price = serializers.SerializerMethodField()
lowest_price = serializers.SerializerMethodField()
image_urls = serializers.SerializerMethodField()
subscription_set = SubscriptionSerializer(many=True)
def __render_price_dict(self, price):
"""
Renders price instance as dict
:param price: price instance
:type price: Price
:return: price instance as dict
:rtype: dict
"""
return {
'value': price.value,
'currency': price.currency,
'date_seen': price.date_seen,
}
def get_current_price(self, obj):
"""
Renderes current price dict as read only value into product representation
:param obj: product to get price for
:type obj: Product
:returns: Dict with current price values
:rtype: dict
"""
if obj.current_price:
return self.__render_price_dict(obj.current_price)
def get_highest_price(self, obj):
"""
Renders highest price dict as read only value into product representation
:param obj: product to get price for
:type obj: Product
:returns: Dict with highest price values
:rtype: dict
"""
if obj.highest_price:
return self.__render_price_dict(obj.highest_price)
def get_lowest_price(self, obj):
"""
Renders lowest price dict as read only value into product representation
:param obj: product to get price for
:type obj: Product
:returns: Dict with lowest price values
:rtype: dict
"""
if obj.lowest_price:
return self.__render_price_dict(obj.lowest_price)
def get_image_urls(self, obj):
"""
Renders image urls as read only value into product representation
:param obj: object to get image urls for
:type obj: Product
:returns: dict with image urls
:rtype: dict
"""
return obj.get_image_urls()
@transaction.atomic
def create(self, validated_data):
"""
Overwriting default create function to ensure, that the already existing instance of product is used, if asin is already in database
:param validated_data: valid form data
:type validated_data: dict
:return: created or fetched product
:rtype: Product
"""
# product = Product.objects.get_or_create(asin=validated_data['asin'])[0]
try:
product = Product.objects.get(asin__iexact=validated_data['asin'])
except Product.DoesNotExist:
product = Product.objects.create(asin=validated_data['asin'])
for new_subscription in validated_data['subscription_set']:
# first fetch EmailNotification object
email_notification = EmailNotification.objects.get_or_create(
owner=self.context['request'].user,
email=new_subscription['email_notification']['email']
)[0]
# don't create double subscriptions with same price limit
product.subscription_set.get_or_create(
owner=self.context['request'].user,
price_limit=new_subscription['price_limit'],
email_notification=email_notification
)
return product
def update(self, instance, validated_data):
"""
Overwrites parent function to enable update of products subscriptions
:param instance: the product instance
:type instance: Product
:param validated_data: dict with validated data from request
:type validated_data: dict
:returns: Updated product instance (in fact there are only updates to subscriptions)
:rtype: Product
"""
new_public_ids = []
for value_dict in validated_data['subscription_set']:
# get public_id if there is any
public_id = value_dict.get('public_id')
new_public_ids.append(public_id)
if public_id:
subscription = Subscription.objects.get_or_create(public_id=public_id)[0]
else:
# this is a new line!
subscription = Subscription()
subscription.product = instance
subscription.owner = self.context['request'].user
subscription.price_limit = value_dict['price_limit']
# simply create email notifcation object if this is a new address
subscription.email_notification = EmailNotification.objects.get_or_create(
owner=self.context['request'].user,
email=value_dict['email_notification']['email']
)[0]
subscription.save()
# remove all subscriptions not in new set subscriptions
instance.subscription_set.filter(owner=self.context['request'].user).exclude(public_id__in=new_public_ids).delete()
return self.context['view'].filter_queryset(self.context['view'].get_queryset()).get(pk=instance.pk)
class Meta(object):
"""Some model meta"""
model = Product
fields = (
'date_creation',
'date_updated',
'date_last_synced',
'status',
'subscription_set',
# amazon specific fields
'asin',
'title',
'artist',
'isbn',
'eisbn',
'binding',
'date_publication',
'date_release',
# amazon urls
'image_urls',
'offer_url',
'current_price',
'highest_price',
'lowest_price',
)
# TODO: check if this is good
read_only_fields = (
'date_creation',
'date_updated',
'date_last_synced',
'status',
# amazon specific fields
'title',
'artist',
'isbn',
'eisbn',
'author',
'publisher',
'label',
'manufacturer',
'brand',
'binding',
'pages',
'date_publication',
'date_release',
'edition',
'model',
'part_number',
# amazon urls
'image_urls',
'offer_url',
)
================================================
FILE: price_monitor/api/serializers/SubscriptionSerializer.py
================================================
"""Serializer for Subscription model"""
from .EmailNotificationSerializer import EmailNotificationSerializer
from ...models import Subscription
from rest_framework import serializers
class SubscriptionSerializer(serializers.ModelSerializer):
"""Serializes subscription with product inline. Also renders id frm public_id"""
# this field needs to be writable to get it's value into update function of ProductSerializer
id = serializers.CharField(source='public_id', required=False)
email_notification = EmailNotificationSerializer()
class Meta(object):
"""Some model meta"""
model = Subscription
fields = (
'id',
'price_limit',
'date_last_notification',
'email_notification',
)
read_only_fields = (
'date_last_notification',
)
================================================
FILE: price_monitor/api/serializers/__init__.py
================================================
================================================
FILE: price_monitor/api/urls.py
================================================
from django.conf.urls import url
from .views.EmailNotificationListView import EmailNotificationListView
from .views.PriceListView import PriceListView
from .views.ProductListView import ProductListView
from .views.ProductCreateRetrieveUpdateDestroyAPIView import ProductCreateRetrieveUpdateDestroyAPIView
from .views.SubscriptionRetrieveView import SubscriptionRetrieveView
from .views.SubscriptionListView import SubscriptionListView
urlpatterns = [
url(r'^email-notifications/$', EmailNotificationListView.as_view(), name='api_email_notification_list'),
url(r'^products/(?P<asin>[0-9a-zA-Z_-]+)/prices/$', PriceListView.as_view(), name='api_product_price_list'),
url(r'^products/(?P<asin>[0-9a-zA-Z_-]+)/$', ProductCreateRetrieveUpdateDestroyAPIView.as_view(), name='api_product_retrieve'),
url(r'^products/$', ProductListView.as_view(), name='api_product_list'),
url(
r'^subscriptions/(?P<public_id>(:public_id|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}))/$',
SubscriptionRetrieveView.as_view(),
name='api_subscription_retrieve'
),
url(r'^subscriptions/$', SubscriptionListView.as_view(), name='api_subscription_list'),
]
================================================
FILE: price_monitor/api/views/EmailNotificationListView.py
================================================
"""View for listing email notifications"""
from ..serializers.EmailNotificationSerializer import EmailNotificationSerializer
from ...models.EmailNotification import EmailNotification
from rest_framework import generics, mixins, permissions
class EmailNotificationListView(mixins.CreateModelMixin, generics.ListAPIView):
"""View for rendering list of EmailNotification objects"""
model = EmailNotification
serializer_class = EmailNotificationSerializer
permission_classes = [
# only return the list if user is authenticated
permissions.IsAuthenticated
]
def post(self, request, *args, **kwargs):
"""
Add post method to create object
:param request: the request
:type request: HttpRequest
:return: Result of creation
:rtype: HttpResponse
"""
return self.create(request, *args, **kwargs)
def get_queryset(self):
"""
Filters queryset by the authenticated user
:returns: filtered EmailNotification objects
:rtype: QuerySet
"""
return self.model.objects.filter(owner=self.request.user)
================================================
FILE: price_monitor/api/views/PriceListView.py
================================================
"""View for listing prices"""
from ..renderers.PriceChartPNGRenderer import PriceChartPNGRenderer
from ..serializers.PriceSerializer import PriceSerializer
from ...models.Price import Price
from datetime import timedelta
from django.utils import timezone
from rest_framework.generics import ListAPIView
class PriceListView(ListAPIView):
model = Price
serializer_class = PriceSerializer
renderer_classes = ListAPIView.renderer_classes + [PriceChartPNGRenderer]
def get_queryset(self):
"""
Returns the elements matching the product's ASIN within the last 7 days.
:return: QuerySet
"""
# FIXME this has room fro improvement, we could only show the values that changes within a wider time range - but currently I don't know how to do that
return self.model.objects.filter(
product__asin=self.kwargs.get('asin'),
date_seen__gte=timezone.now() - timedelta(days=7),
).order_by('-date_seen')
================================================
FILE: price_monitor/api/views/ProductCreateRetrieveUpdateDestroyAPIView.py
================================================
"""Mixed view for API"""
from .mixins.ProductFilteringMixin import ProductFilteringMixin
from ..serializers.ProductSerializer import ProductSerializer
from ...models.Product import Product
from rest_framework import generics, mixins, permissions
class ProductCreateRetrieveUpdateDestroyAPIView(ProductFilteringMixin, mixins.CreateModelMixin, generics.RetrieveUpdateDestroyAPIView):
"""Returns single instance of Product, if user is authenticated"""
model = Product
serializer_class = ProductSerializer
lookup_field = 'asin'
permission_classes = [
# only return the product if user is authenticated
permissions.IsAuthenticated
]
def post(self, request, *args, **kwargs):
"""
Add post method to create object
:param request: the request
:type request: HttpRequest
:return: Result of creation
:rtype: HttpResponse
"""
return self.create(request, *args, **kwargs)
def get_queryset(self):
"""
Filters queryset by the authenticated user
:returns: filtered Product objects
:rtype: QuerySet
"""
# distinct is needed to prevent multiple instances of product in resultset if multiple subscriptions are present
return self.model.objects.filter(subscription__owner=self.request.user).distinct()
def perform_destroy(self, instance):
"""
Overwrite base function to delete subscriptions, not the product itself
:param instance: the product to delete subscriptions from
:type instance: Product
"""
instance.subscription_set.filter(owner=self.request.user).delete()
================================================
FILE: price_monitor/api/views/ProductListView.py
================================================
"""View for listing subscriptions"""
from rest_framework import generics, permissions
from .mixins.ProductFilteringMixin import ProductFilteringMixin
from ..serializers.ProductSerializer import ProductSerializer
from ...models.Product import Product
class ProductListView(ProductFilteringMixin, generics.ListAPIView):
"""Returns list of Products and provides endpoint to create Products, if user is authenticated."""
model = Product
serializer_class = ProductSerializer
allow_empty = True
queryset = Product.objects.all()
permission_classes = [
# only return the list if user is authenticated
permissions.IsAuthenticated
]
================================================
FILE: price_monitor/api/views/SubscriptionListView.py
================================================
"""View for listing subscriptions"""
from ..serializers.SubscriptionSerializer import SubscriptionSerializer
from ...models.Subscription import Subscription
from rest_framework import generics, permissions
class SubscriptionListView(generics.ListAPIView):
"""Returns list of subscriptions, if user is authenticated"""
model = Subscription
serializer_class = SubscriptionSerializer
allow_empty = True
permission_classes = [
# only return the list if user is authenticated
permissions.IsAuthenticated
]
def get_queryset(self):
"""
Filters queryset by the authenticated user
:returns: filtered Subscription objects
:rtype: QuerySet
"""
return self.model.objects.filter(owner=self.request.user)
================================================
FILE: price_monitor/api/views/SubscriptionRetrieveView.py
================================================
"""View for retrieving a subscription"""
from ..serializers.SubscriptionSerializer import SubscriptionSerializer
from ...models.Subscription import Subscription
from rest_framework import generics, permissions
class SubscriptionRetrieveView(generics.RetrieveAPIView):
"""Returns instance of Subscription, if user is authenticated"""
model = Subscription
serializer_class = SubscriptionSerializer
lookup_field = 'public_id'
permission_classes = [
# only return the list if user is authenticated
permissions.IsAuthenticated
]
def get_queryset(self):
"""
Filters queryset by the authenticated user
:returns: filtered Subscription objects
:rtype: QuerySet
"""
return self.model.objects.filter(owner=self.request.user)
================================================
FILE: price_monitor/api/views/__init__.py
================================================
================================================
FILE: price_monitor/api/views/mixins/ProductFilteringMixin.py
================================================
"""Mixin for product filtering"""
from django.db.models.query import Prefetch
from ....models.Subscription import Subscription
class ProductFilteringMixin(object):
"""Mixin for filtering products of the current user and have the lowest, highest and current price included."""
def filter_queryset(self, queryset):
"""
Filters queryset by the authenticated user
:returns: filtered Product objects
:rtype: QuerySet
"""
queryset = super(ProductFilteringMixin, self).filter_queryset(queryset)
return queryset\
.select_related('highest_price', 'lowest_price', 'current_price')\
.prefetch_related(
Prefetch(
'subscription_set',
queryset=Subscription.objects.filter(
owner=self.request.user
).select_related('email_notification').distinct()
)
).filter(subscription__owner=self.request.user).distinct()
================================================
FILE: price_monitor/api/views/mixins/__init__.py
================================================
================================================
FILE: price_monitor/app_settings.py
================================================
from django.conf import settings
from django.utils.translation import ugettext_lazy
# global AWS access settings
PRICE_MONITOR_AWS_ACCESS_KEY_ID = getattr(settings, 'PRICE_MONITOR_AWS_ACCESS_KEY_ID', '')
PRICE_MONITOR_AWS_SECRET_ACCESS_KEY = getattr(settings, 'PRICE_MONITOR_AWS_SECRET_ACCESS_KEY', '')
PRICE_MONITOR_AMAZON_PRODUCT_API_REGION = getattr(settings, 'PRICE_MONITOR_AMAZON_PRODUCT_API_REGION', '')
PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG = getattr(settings, 'PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG', '')
PRICE_MONITOR_AMAZON_ASSOCIATE_NAME = getattr(settings, 'PRICE_MONITOR_AMAZON_ASSOCIATE_NAME', '<ADJUST settings.PRICE_MONITOR_AMAZON_ASSOCIATE_NAME>')
# project settings
PRICE_MONITOR_BASE_URL = getattr(settings, 'PRICE_MONITOR_BASE_URL', 'http://localhost:8000')
# Amazon Disclaimers
# Disclaimer for Product Advertising API, see https://partnernet.amazon.de/gp/advertising/api/detail/agreement.html and #12
PRICE_MONITOR_AMAZON_PRODUCT_ADVERTISING_API_DISCLAIMER = 'CERTAIN CONTENT THAT APPEARS ON THIS SITE COMES FROM AMAZON EU S.à r.l. THIS CONTENT IS ' \
'PROVIDED \'AS IS\' AND IS SUBJECT TO CHANGE OR REMOVAL AT ANY TIME.'
# Disclaimer for Amazon associates, see https://partnernet.amazon.de/gp/associates/agreement (10) and #77
# available sites for disclaimer text, just as reference, unused in code
PRICE_MONITOR_AMAZON_ASSOCIATE_SITES = [
'Amazon.co.uk',
'Local.Amazon.co.uk',
'Amazon.de',
'de.BuyVIP.com',
'Amazon.fr',
'Amazon.it',
'it.BuyVIP.com',
'Amazon.es',
'es.BuyVIP.com',
]
PRICE_MONITOR_AMAZON_ASSOCIATE_SITE = getattr(settings, 'PRICE_MONITOR_AMAZON_ASSOCIATE_SITE', '<ADJUST settings.PRICE_MONITOR_AMAZON_ASSOCIATE_SITE>')
# Disclaimer for Amazon associates, see https://partnernet.amazon.de/gp/associates/agreement (10) and #77
PRICE_MONITOR_ASSOCIATE_DISCLAIMER = '{name} is a participant in the Amazon EU Associates Programme, an affiliate advertising programme designed to provide' \
' a means for sites to earn advertising fees by advertising and linking to {amazon_site_name}.'.format(
name=PRICE_MONITOR_AMAZON_ASSOCIATE_NAME,
amazon_site_name=PRICE_MONITOR_AMAZON_ASSOCIATE_SITE,
)
# server infrastructural settings
# serve the product images via HTTPS
PRICE_MONITOR_IMAGES_USE_SSL = getattr(settings, 'PRICE_MONITOR_IMAGES_USE_SSL', True)
# HTTPS host to use for getting the images. Seems to be https://images-<REGION>.ssl-images-amazon.com.
PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN = getattr(settings, 'PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN', 'https://images-eu.ssl-images-amazon.com')
# synchronization settings
# refresh product after 12 hours
PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES = int(getattr(settings, 'PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES', 12 * 60))
# notification settings
# time after when to notify about a subscription again
PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES = int(getattr(settings, 'PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES', 60 * 24 * 7))
# the email sender for notification emails
PRICE_MONITOR_EMAIL_SENDER = getattr(settings, 'PRICE_MONITOR_EMAIL_SENDER', 'noreply@localhost')
# default currency
PRICE_MONITOR_DEFAULT_CURRENCY = getattr(settings, 'PRICE_MONITOR_DEFAULT_CURRENCY', 'EUR')
# i18n for email notifications
PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_SUBJECT = getattr(
settings,
'PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_SUBJECT',
ugettext_lazy('Price limit for %(product)s reached')
)
PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_BODY = getattr(
settings,
'PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_BODY',
ugettext_lazy(
'The price limit of {price_limit:0.2f} {currency:s} has been reached for the article "{product_title:s}"\n'
'Current price is {price:0.2f} {currency:s} ({price_date:s}).'
'\n\n'
'Please support our platform by using this affiliate link for buying the product: {url_product_amazon:s}'
'\n'
'Adjust the price limits for the products here: {url_product_detail:s}'
'\n\n'
'{additional_text:s}'
'\n'
'Regards,'
'\n'
'The Team'
)
)
PRICE_MONITOR_SITENAME = getattr(settings, 'PRICE_MONITOR_SITENAME', 'Price Monitor')
# cache settings
# key of cache (according to project config) to use for graphs. Set to none to disable caching
PRICE_MONITOR_GRAPH_CACHE_NAME = getattr(settings, 'PRICE_MONITOR_GRAPH_CACHE_NAME', None)
# prefix for cache key used for graphs
PRICE_MONITOR_GRAPH_CACHE_KEY_PREFIX = getattr(settings, 'PRICE_MONITOR_GRAPH_CACHE_KEY_PREFIX', 'graph_')
# internal settings - not to be overwritten by user
# Regex for ASIN validation
PRICE_MONITOR_ASIN_REGEX = r'[A-Z0-9\-]+'
# Product Advertising API relevant settings
# TODO is there a possibility to only get get attributes we need? I have the feeling that 75% of the data is irrelevant for us.
PRICE_MONITOR_PA_RESPONSE_GROUP = 'Large'
# mapping of PRICE_MONITOR_AMAZON_PRODUCT_API_REGION to the appropriate amazon domain ending
PRICE_MONITOR_AMAZON_REGION_DOMAINS = {
'CA': 'ca',
'DE': 'de',
'ES': 'es',
'FR': 'fr',
'IN': 'in',
'IT': 'it',
'JP': 'co.jp',
'UK': 'co.uk',
'US': 'com',
}
PRICE_MONITOR_OFFER_URL = 'http://www.amazon.{domain:s}/dp/{asin:s}/?tag={assoc_tag:s}'
================================================
FILE: price_monitor/forms.py
================================================
"""Form definitions for frontend"""
from . import app_settings as settings
from .models.EmailNotification import EmailNotification
from .models.Product import Product
from .models.Subscription import Subscription
from django import forms
from django.utils.translation import ugettext as _
class SubscriptionCreationForm(forms.ModelForm):
"""Form for creating an product Subscription"""
product = forms.RegexField(label=_('ASIN'), regex=settings.PRICE_MONITOR_ASIN_REGEX)
email_notification = forms.ModelChoiceField(queryset=EmailNotification.objects.all(), empty_label=None)
def clean_product(self):
"""
At creation, user gives an ASIN. But for saving the model, a product instance is needed.
So this product is looked up or created if not present here.
"""
asin = self.cleaned_data['product']
try:
product = Product.objects.get(asin__iexact=asin)
except Product.DoesNotExist:
product = Product.objects.create(asin=asin)
asin = product
return asin
class Meta(object):
"""Form meta stuff"""
fields = ('product', 'email_notification', 'price_limit', 'owner')
model = Subscription
widgets = {
'owner': forms.HiddenInput(),
}
class SubscriptionUpdateForm(forms.ModelForm):
"""Form for updating a subscription"""
class Meta(object):
"""Form meta stuff"""
fields = ('product', 'email_notification', 'price_limit', 'owner')
model = Subscription
widgets = {
'owner': forms.HiddenInput(),
'product': forms.TextInput(attrs={'readonly': True}),
}
class EmailNotificationForm(forms.ModelForm):
"""Form for giving an email notification"""
class Meta(object):
"""Form meta stuff"""
fields = ('email', 'owner')
model = EmailNotification
widgets = {
'owner': forms.HiddenInput(),
}
================================================
FILE: price_monitor/locale/de/LC_MESSAGES/django.po
================================================
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-10-28 19:33+0100\n"
"PO-Revision-Date: 2015-10-28 19:42+0100\n"
"Last-Translator: Alexander Herrmann <darignac@gmail.com>\n"
"Language-Team: Deutsch <darignac@gmail.com>\n"
"Language: Deutsch\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.5.4\n"
"X-Poedit-SourceCharset: UTF-8\n"
#: admin.py:27
msgid "Reset to status \"Created\"."
msgstr "Auf Status \"Erstellt\" zurücksetzen."
#: admin.py:38
msgid "Resynchronize with API"
msgstr "Mit API resynchronisieren"
#: app_settings.py:32
#, python-format
msgid "Price limit for %(product)s reached"
msgstr "Preislimit für %(product)s erreicht"
#: app_settings.py:38
#, python-format
msgid ""
"The price limit of %(price_limit)0.2f %(currency)s has been reached for the "
"article \"%(product_title)s\" - the current price is %(price)0.2f "
"%(currency)s.\n"
"\n"
"Please support our platform by using this link for buying: %(link)s\n"
"\n"
"\n"
"Regards,\n"
"The Team"
msgstr ""
"Das Preislimit von %(price_limit)0.2f %(currency)s wurde für den Artikel "
"\"%(product_title)s\" erreicht - der aktuelle Preis ist %(price)0.2f "
"%(currency)s.\n"
"\n"
"Bitte unterstütze unsere Plattform, indem du den Artikel über diesen Link "
"einkaufst: %(link)s\n"
"\n"
"\n"
"Grüße,\n"
"Das Team"
#: forms.py:14 models/Product.py:40
msgid "ASIN"
msgstr "ASIN"
#: models/EmailNotification.py:14 models/Subscription.py:12
msgid "Owner"
msgstr "Besitzer"
#: models/EmailNotification.py:15
msgid "Email address"
msgstr "E-Mail-Adresse"
#: models/EmailNotification.py:31 models/Subscription.py:16
msgid "Email Notification"
msgstr "E-Mail-Benachrichtigung"
#: models/EmailNotification.py:32
msgid "Email Notifications"
msgstr "E-Mail-Benachrichtigungen"
#: models/Price.py:9 models/Price.py:29
msgid "Price"
msgstr "Preis"
#: models/Price.py:10
msgid "Currency"
msgstr "Währung"
#: models/Price.py:11
msgid "Date of price"
msgstr "Datum des Preises"
#: models/Price.py:12 models/Product.py:127 models/Subscription.py:13
msgid "Product"
msgstr "Produkt"
#: models/Price.py:30
msgid "Prices"
msgstr "Preise"
#: models/Product.py:23
msgid "Created"
msgstr "Erstellt"
#: models/Product.py:24
msgid "Synced over API"
msgstr "über API synchronisiert"
#: models/Product.py:25
msgid "Unsynchable"
msgstr "nicht synchronisierbar"
#: models/Product.py:29
msgid "Date of creation"
msgstr "Erstellungsdatum"
#: models/Product.py:30
msgid "Date of last update"
msgstr "Datum der letzten Aktualisierung"
#: models/Product.py:31
msgid "Date of last synchronization"
msgstr "Datum der letzten Synchronisierung"
#: models/Product.py:34
msgid "Status"
msgstr "Status"
#: models/Product.py:37
msgid "Subscribers"
msgstr "Abonnenten"
#: models/Product.py:41
msgid "Title"
msgstr "Titel"
#: models/Product.py:42
msgid "Artist"
msgstr "Künstler"
#: models/Product.py:43
msgid "ISBN"
msgstr "ISBN"
#: models/Product.py:44
msgid "E-ISBN"
msgstr "E-ISBN"
#: models/Product.py:45
msgid "Binding"
msgstr "Bindung"
#: models/Product.py:46
msgid "Publication date"
msgstr "Erscheinungsdatum"
#: models/Product.py:47
msgid "Release date"
msgstr "Freigabedatum"
#: models/Product.py:48
msgid "Audience rating"
msgstr "Zielgruppe"
#: models/Product.py:49
msgid "URL to large product image"
msgstr "URL zum großen Produktbild"
#: models/Product.py:50
msgid "URL to medium product image"
msgstr "URL zum mittleren Produktbild"
#: models/Product.py:51
msgid "URL to small product image"
msgstr "URL zum kleinen Produktbild"
#: models/Product.py:52
msgid "URL to the offer"
msgstr "URL zum Angebot"
#: models/Product.py:54
#| msgid "Currency"
msgid "Current price"
msgstr "Aktueller Preis"
#: models/Product.py:55
msgid "Highest price ever"
msgstr "Höchster Preis"
#: models/Product.py:56
msgid "Lowest price ever"
msgstr "Niedrigster Preis"
#: models/Product.py:114
msgid "Unsynchronized Product"
msgstr "Nicht synchronisiertes Produkt"
#: models/Product.py:128
msgid "Products"
msgstr "Produkte"
#: models/Subscription.py:14
msgid "Price limit"
msgstr "Preislimit"
#: models/Subscription.py:15
msgid "Date of last sent notification"
msgstr "Datum der zuletzt gesendeten Benachrichtigung"
#: models/Subscription.py:24
msgid "Notification email"
msgstr "Benachrichtigungs-E-Mail"
#: models/Subscription.py:39
msgid "Subscription"
msgstr "Abonnement"
#: models/Subscription.py:40
msgid "Subscriptions"
msgstr "Abonnements"
#: models/mixins/PublicIDMixin.py:17
msgid "Public-ID"
msgstr "Public-ID"
#~ msgid "Email notifications"
#~ msgstr "E-Mail-Benachrichtigungen"
#~ msgid "Add new email notifications"
#~ msgstr "Neue E-Mail-Benachrichtigungen hinzufügen"
#~ msgid "Already monitored products"
#~ msgstr "Bereits überwachte Produkte"
#~ msgid ""
#~ "Product prices and availability are accurate as of the date/time "
#~ "indicated and are subject to change. Any price and availability "
#~ "information displayed on %(site_name)s at the time of purchase will apply "
#~ "to the purchase of this product."
#~ msgstr ""
#~ "Produktpreise und -verfügbarkeit gelten zur angezeigten Zeit und können "
#~ "sich ändern. Jede Preis- und Verügbarkeitsinformation, die auf "
#~ "%(site_name)s zum Zeitpunkt der Bestellung angezeigt werden, treffen auch "
#~ "für den Kauf des Produktes zu."
#~ msgid "Details"
#~ msgstr "Details"
#~ msgid "No price information available."
#~ msgstr "Keine Preisinformationen verfügbar."
#~ msgid "Limit"
#~ msgstr "Limit"
#~ msgid "Remove"
#~ msgstr "Entfernen"
#~ msgid "Add new products"
#~ msgstr "Neue Produkte hinzufügen"
#~ msgid "Monitor ASINs"
#~ msgstr "ASINs überwachen"
#~ msgid "No price data available."
#~ msgstr "Keine Preisdaten verfügbar."
#~ msgid "at"
#~ msgstr "um"
#~ msgid "Please specify a list of ASINs as only argument, separated by comma!"
#~ msgstr ""
#~ "Bitte gib eine Liste von kommaseparierten ASINs als einziges Argument an!"
#~ msgid "Please specify a single ASIN as only argument!"
#~ msgstr "Bitte gib eine einzige ASIN als einziges Argument an!"
================================================
FILE: price_monitor/management/__init__.py
================================================
================================================
FILE: price_monitor/management/commands/__init__.py
================================================
================================================
FILE: price_monitor/management/commands/price_monitor_batch_create_products.py
================================================
"""Management command for batch reation of products"""
from django.core.management.base import BaseCommand
from price_monitor.models import Product
class Command(BaseCommand):
"""Command for batch creating of products."""
help = 'Creates multiple products from the given ASIN list. Skips products already in database.'
def add_arguments(self, parser):
"""
Adds the positional argument for ASINs
:param parser: the argument parser
"""
parser.add_argument('asins', nargs='+', type=str)
def handle(self, *args, **options):
"""Batch create products from given ASIN list."""
# get all products with given asins
product_asins = [p.asin for p in Product.objects.filter(asin__in=options['asins'])]
# remove the asins that are already there
asins = [a for a in options['asins'] if a not in product_asins]
# create some products
for asin in asins:
Product.objects.create(asin=asin)
print('created {0:d} products'.format(len(asins)))
================================================
FILE: price_monitor/management/commands/price_monitor_clean_db.py
================================================
"""Management command for removing invalid data from database"""
from django.core.management.base import BaseCommand
from price_monitor.models import (
Price,
Product,
)
class Command(BaseCommand):
"""Command for cleaning the database. Deletes all products without subscriptions."""
help = 'Deletes all products without subscriptions'
def handle(self, *args, **options):
"""Deletes the products without subscriptions."""
products_without_subscribers = Product.objects.filter(subscribers__isnull=True)
prices_without_subscribers = Price.objects.filter(product__subscribers__isnull=True)
print('=== PRE-CLEANUP ==================================')
print('Product count: {0:20d}'.format(Product.objects.count()))
print('Products with subscribers: {0:20d}'.format(Product.objects.filter(subscribers__isnull=False).count()))
print('Products without subscribers: {0:20d}'.format(products_without_subscribers.count()))
print('Prices count: {0:20d}'.format(Price.objects.count()))
print('==================================================')
print('')
choice = input(
'{0:d} products with {1:d} prices will be deleted, continue? [y/N]'.format(
products_without_subscribers.count(),
prices_without_subscribers.count()
)
)
if choice in ['y', 'Y']:
products_without_subscribers.delete()
prices_without_subscribers.delete()
print('')
print('DONE')
================================================
FILE: price_monitor/management/commands/price_monitor_recreate_product.py
================================================
"""Management command for recreating a product"""
from django.core.management.base import BaseCommand
from price_monitor.models import Product
class Command(BaseCommand):
help = 'Recreates a product with the given asin. If product already exists, it is deleted.'
def add_arguments(self, parser):
"""
Adds the positional argument for ASIN
:param parser: the argument parser
"""
parser.add_argument('asin', nargs=1, type=str)
def handle(self, *args, **options):
"""Recreates the product with given ASIN"""
asin = options['asin'][0]
product, created = Product.objects.get_or_create(asin=asin)
if not created:
product.delete()
Product.objects.create(asin=asin)
================================================
FILE: price_monitor/management/commands/price_monitor_search.py
================================================
"""Management command for searching Amazon"""
from django.core.management.base import BaseCommand
from price_monitor.product_advertising_api.api import ProductAdvertisingAPI
from pprint import pprint
class Command(BaseCommand):
"""Command for searching ASINs and displaying their return value"""
help = 'Searches for products at Amazon (not within the database!) with the given ASINs and prints out their details.'
def add_arguments(self, parser):
"""
Adds the positional argument for ASINs.
:param parser: the argument parser
"""
parser.add_argument('asins', nargs='+', type=str)
def handle(self, *args, **options):
"""Searches for a product with the given ASIN."""
asins = options['asins']
api = ProductAdvertisingAPI()
pprint(api.item_lookup(asins), indent=4)
================================================
FILE: price_monitor/management/commands/price_monitor_send_test_mail.py
================================================
"""Management command for sending a pricemonitor specific test email"""
from datetime import datetime
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
from price_monitor.models import (
EmailNotification,
Price,
Product,
Subscription,
)
from price_monitor.utils import send_mail
class Command(BaseCommand):
"""Command for sending a pricemonitor specific test email"""
help = 'Sends a pricemonitor specific test email'
def add_arguments(self, parser):
"""
Adds the positional argument for the email address.
:param parser: the argument parser
"""
parser.add_argument('email', nargs='+', type=str)
def handle(self, *args, **options):
"""Sends an email."""
u = User()
e = EmailNotification()
e.owner = u
e.email = options['email'][0]
p = Product()
p.asin = 'ASIN123'
p.title = 'Dummy Product'
p.offer_url = 'http://localhost/offer'
s = Subscription()
s.price_limit = 9.99
s.email_notification = e
r = Price()
r.value = 8.00
r.currency = 'EUR'
r.date_seen = datetime.now()
r.product = p
send_mail(
p,
s,
r,
additional_text='This is a test email.'
)
================================================
FILE: price_monitor/migrations/0001_initial.py
================================================
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmailNotification',
fields=[
('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)),
('public_id', models.CharField(db_index=True, unique=True, editable=False, verbose_name='Public-ID', max_length=36)),
('email', models.EmailField(verbose_name='Email address', max_length=254)),
('owner', models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name='Owner')),
],
options={
'ordering': ('email',),
'verbose_name': 'Email Notification',
'verbose_name_plural': 'Email Notifications',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Price',
fields=[
('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)),
('value', models.FloatField(verbose_name='Price')),
('currency', models.CharField(verbose_name='Currency', max_length=3)),
('date_seen', models.DateTimeField(verbose_name='Date of price')),
],
options={
'ordering': ('date_seen',),
'get_latest_by': 'date_seen',
'verbose_name': 'Price',
'verbose_name_plural': 'Prices',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)),
('date_creation', models.DateTimeField(verbose_name='Date of creation', auto_now_add=True)),
('date_updated', models.DateTimeField(verbose_name='Date of last update', auto_now=True)),
('date_last_synced', models.DateTimeField(null=True, verbose_name='Date of last synchronization', blank=True)),
('status', models.SmallIntegerField(verbose_name='Status', choices=[(0, 'Created'), (1, 'Synced over API'), (2, 'Unsynchable')], default=0)),
('asin', models.CharField(unique=True, verbose_name='ASIN', max_length=100)),
('title', models.CharField(null=True, verbose_name='Title', blank=True, max_length=255)),
('isbn', models.CharField(null=True, verbose_name='ISBN', blank=True, max_length=10)),
('eisbn', models.CharField(null=True, verbose_name='E-ISBN', blank=True, max_length=13)),
('binding', models.CharField(null=True, verbose_name='Binding', blank=True, max_length=255)),
('date_publication', models.DateField(null=True, verbose_name='Publication date', blank=True)),
('date_release', models.DateField(null=True, verbose_name='Release date', blank=True)),
('audience_rating', models.CharField(null=True, verbose_name='Audience rating', blank=True, max_length=255)),
('large_image_url', models.URLField(null=True, verbose_name='URL to large product image', blank=True)),
('medium_image_url', models.URLField(null=True, verbose_name='URL to medium product image', blank=True)),
('small_image_url', models.URLField(null=True, verbose_name='URL to small product image', blank=True)),
('offer_url', models.URLField(null=True, verbose_name='URL to the offer', blank=True)),
],
options={
'ordering': ('title', 'asin'),
'verbose_name': 'Product',
'verbose_name_plural': 'Products',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)),
('public_id', models.CharField(db_index=True, unique=True, editable=False, verbose_name='Public-ID', max_length=36)),
('price_limit', models.FloatField(verbose_name='Price limit')),
('date_last_notification', models.DateTimeField(null=True, verbose_name='Date of last sent notification', blank=True)),
('email_notification', models.ForeignKey(to='price_monitor.EmailNotification', verbose_name='Email Notification')),
('owner', models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name='Owner')),
('product', models.ForeignKey(to='price_monitor.Product', verbose_name='Product')),
],
options={
'ordering': ('product__title', 'price_limit', 'email_notification__email'),
'verbose_name': 'Subscription',
'verbose_name_plural': 'Subscriptions',
},
bases=(models.Model,),
),
migrations.AddField(
model_name='product',
name='subscribers',
field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, verbose_name='Subscribers', through='price_monitor.Subscription'),
preserve_default=True,
),
migrations.AddField(
model_name='price',
name='product',
field=models.ForeignKey(to='price_monitor.Product', verbose_name='Product'),
preserve_default=True,
),
]
================================================
FILE: price_monitor/migrations/0002_add_min_max_fk_to_product.py
================================================
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('price_monitor', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='product',
name='highest_price',
field=models.ForeignKey(related_name='product_highest', to='price_monitor.Price', blank=True, verbose_name='Highest price ever', null=True),
),
migrations.AddField(
model_name='product',
name='lowest_price',
field=models.ForeignKey(related_name='product_lowest', to='price_monitor.Price', blank=True, verbose_name='Lowest price ever', null=True),
),
migrations.AddField(
model_name='product',
name='current_price',
field=models.ForeignKey(to='price_monitor.Price', null=True, blank=True, verbose_name='Current price', related_name='product_current'),
),
]
================================================
FILE: price_monitor/migrations/0003_datamigration_for_min_max_cur_fks.py
================================================
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
def set_prices(apps, schema_editor):
"""
Sets min, max and current price
"""
for product in apps.get_model('price_monitor', 'Product').objects.all():
if product.price_set.count() > 0:
product.current_price = product.price_set.latest('date_seen')
product.highest_price = product.price_set.latest('value')
product.lowest_price = product.price_set.earliest('value')
product.save()
class Migration(migrations.Migration):
dependencies = [
('price_monitor', '0002_add_min_max_fk_to_product'),
]
operations = [
migrations.RunPython(set_prices, reverse_code=migrations.RunPython.noop),
]
================================================
FILE: price_monitor/migrations/0004_make_price_and_currency_nullable.py
================================================
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('price_monitor', '0003_datamigration_for_min_max_cur_fks'),
]
operations = [
migrations.AlterField(
model_name='price',
name='currency',
field=models.CharField(null=True, verbose_name='Currency', blank=True, max_length=3),
),
migrations.AlterField(
model_name='price',
name='value',
field=models.FloatField(null=True, verbose_name='Price', blank=True),
),
]
================================================
FILE: price_monitor/migrations/0005_product_artist.py
================================================
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('price_monitor', '0004_make_price_and_currency_nullable'),
]
operations = [
migrations.AddField(
model_name='product',
name='artist',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Artist'),
),
]
================================================
FILE: price_monitor/migrations/__init__.py
================================================
================================================
FILE: price_monitor/models/EmailNotification.py
================================================
"""Model for an email based notification"""
from .mixins.PublicIDMixin import PublicIDMixin
from django.conf import settings
from django.db import models
from django.utils.translation import ugettext as _, ugettext_lazy
from six import text_type
class EmailNotification(PublicIDMixin, models.Model):
"""An email notification."""
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_('Owner'))
email = models.EmailField(verbose_name=_('Email address'))
def __str__(self):
"""
Returns the unicode representation of the EmailNotification.
:return: the unicode representation
:rtype: unicode
"""
return text_type(
' {email!s}'.format(**{
'email': self.email,
})
)
class Meta(object):
"""Meta Peter or how to configure your Django model"""
app_label = 'price_monitor'
verbose_name = ugettext_lazy('Email Notification')
verbose_name_plural = ugettext_lazy('Email Notifications')
ordering = ('email',)
================================================
FILE: price_monitor/models/Price.py
================================================
"""Definition of a model for prices"""
from django.db import models
from django.utils.translation import ugettext as _, ugettext_lazy
class Price(models.Model):
"""Representing fetched price for a product"""
value = models.FloatField(verbose_name=_('Price'), blank=True, null=True)
currency = models.CharField(max_length=3, verbose_name=_('Currency'), blank=True, null=True)
date_seen = models.DateTimeField(verbose_name=_('Date of price'))
product = models.ForeignKey('Product', on_delete=models.CASCADE, verbose_name=_('Product'))
def __str__(self):
"""
Returns the string representation of the Product.
:return: the unicode representation
:rtype: unicode
"""
return '{value!s} {currency!s} on {date_seen!s}'.format(**dict(
value='{0:0.2f}'.format(self.value) if self.value else 'No price',
currency=self.currency if self.currency else '',
date_seen=self.date_seen
))
class Meta(object):
app_label = 'price_monitor'
get_latest_by = 'date_seen'
verbose_name = ugettext_lazy('Price')
verbose_name_plural = ugettext_lazy('Prices')
ordering = ('date_seen',)
================================================
FILE: price_monitor/models/Product.py
================================================
"""Model for an Amazon product"""
from django.conf import settings
from django.db import models
from django.utils import formats
from django.utils.translation import (
ugettext as _,
ugettext_lazy,
)
from price_monitor import app_settings
from price_monitor.models.Price import Price
from urllib.parse import (
urljoin,
urlparse,
)
class Product(models.Model):
"""Product to be monitored."""
STATUS_CHOICES = (
(0, _('Created'),),
(1, _('Synced over API'),),
(2, _('Unsynchable'),),
)
# date values
date_creation = models.DateTimeField(auto_now_add=True, verbose_name=_('Date of creation'))
date_updated = models.DateTimeField(auto_now=True, verbose_name=_('Date of last update'))
date_last_synced = models.DateTimeField(blank=True, null=True, verbose_name=_('Date of last synchronization'))
# synchronization status
status = models.SmallIntegerField(choices=STATUS_CHOICES, default=0, verbose_name=_('Status'))
# relations
subscribers = models.ManyToManyField(settings.AUTH_USER_MODEL, through='Subscription', verbose_name=_('Subscribers'))
# amazon specific fields
asin = models.CharField(max_length=100, unique=True, verbose_name=_('ASIN'))
title = models.CharField(blank=True, null=True, max_length=255, verbose_name=_('Title'))
artist = models.CharField(blank=True, null=True, max_length=255, verbose_name=_('Artist'))
isbn = models.CharField(blank=True, null=True, max_length=10, verbose_name=_('ISBN'))
eisbn = models.CharField(blank=True, null=True, max_length=13, verbose_name=_('E-ISBN'))
binding = models.CharField(blank=True, null=True, max_length=255, verbose_name=_('Binding'))
date_publication = models.DateField(blank=True, null=True, verbose_name=_('Publication date'))
date_release = models.DateField(blank=True, null=True, verbose_name=_('Release date'))
audience_rating = models.CharField(blank=True, null=True, max_length=255, verbose_name=_('Audience rating'))
large_image_url = models.URLField(blank=True, null=True, verbose_name=_('URL to large product image'))
medium_image_url = models.URLField(blank=True, null=True, verbose_name=_('URL to medium product image'))
small_image_url = models.URLField(blank=True, null=True, verbose_name=_('URL to small product image'))
offer_url = models.URLField(blank=True, null=True, verbose_name=_('URL to the offer'))
current_price = models.ForeignKey(Price, on_delete=models.CASCADE, blank=True, null=True, related_name='product_current', verbose_name=_('Current price'))
highest_price = models.ForeignKey(Price, on_delete=models.CASCADE, blank=True, null=True, related_name='product_highest',
verbose_name=_('Highest price ever'))
lowest_price = models.ForeignKey(Price, on_delete=models.CASCADE, blank=True, null=True, related_name='product_lowest', verbose_name=_('Lowest price ever'))
def get_prices_for_chart(self):
"""
Returns all prices of the product.
:return: list
"""
# TODO: be able to specify a range, like last 100 days
# TODO: don't select all prices, but a representative representation, like each 5th price aso
return [{'x': str(formats.date_format(p.date_seen, 'SHORT_DATETIME_FORMAT')), 'y': p.value} for p in self.price_set.all().order_by('date_seen')]
def set_failed_to_sync(self):
"""Marks the product as failed to sync. This happens if the Amazon API request for this product fails."""
self.status = 2
self.save()
def get_image_urls(self):
"""
Returns all image urls as dictionary. The size is the key.
Respects HTTP/HTTPS configuration.
:return: image dict
:rtype: dict
"""
return {
'small': self.__get_image_url(self.small_image_url),
'medium': self.__get_image_url(self.medium_image_url),
'large': self.__get_image_url(self.large_image_url),
}
def __get_image_url(self, url):
"""
Returns the correct image url depending on the settings. Will either be a HTTP or HTTPS host.
:param url: the original (HTTP) image url
:return: the adjusted image url if SSL is enabled
"""
if app_settings.PRICE_MONITOR_IMAGES_USE_SSL:
return urljoin(app_settings.PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN, urlparse(url).path)
return url
def get_graph_cache_key(self):
"""
Returns cache key used for caching the price graph
:return: the cache key
:rtype: str
"""
return 'graph-{0!s}-{1!s}'.format(self.asin, self.date_last_synced.isoformat() if self.date_last_synced is not None else '')
def get_title(self):
"""
Returns the title of the product.
:return: the title
:rtype: str
"""
return '{0}{1}'.format(
'{0}: '.format(self.artist) if self.artist is not None and len(self.artist) > 0 else '',
self.title if self.title is not None and len(self.title) > 0 else _('Unsynchronized Product'),
)
def get_detail_url(self):
"""
Returns the url to a product detail view.
As the frontend is AngularJS, we cannot use any Django reverse functionality.
:return: the link
"""
return '{base_url:s}/#/products/{asin:s}'.format(
base_url=app_settings.PRICE_MONITOR_BASE_URL,
asin=self.asin,
)
def __str__(self):
"""
Returns the unicode representation of the Product.
:return: the unicode representation
:rtype: unicode
"""
return '{0} (ASIN: {1})'.format(self.get_title(), self.asin)
class Meta(object):
"""Django meta config"""
app_label = 'price_monitor'
verbose_name = ugettext_lazy('Product')
verbose_name_plural = ugettext_lazy('Products')
ordering = ('title', 'asin',)
================================================
FILE: price_monitor/models/Subscription.py
================================================
"""The subscription model"""
from .mixins.PublicIDMixin import PublicIDMixin
from django.conf import settings
from django.db import models
from django.utils.translation import ugettext as _, ugettext_lazy
class Subscription(PublicIDMixin, models.Model):
"""Model for a user being able to subscribe to a product and be notified if the price_limit is reached."""
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_('Owner'))
product = models.ForeignKey('Product', on_delete=models.CASCADE, verbose_name=_('Product'))
price_limit = models.FloatField(verbose_name=_('Price limit'))
date_last_notification = models.DateTimeField(null=True, blank=True, verbose_name=_('Date of last sent notification'))
email_notification = models.ForeignKey('EmailNotification', on_delete=models.CASCADE, verbose_name=_('Email Notification'))
def get_email_address(self):
"""
Returns the email address of the notification.
:return: string
"""
return self.email_notification.email
get_email_address.short_description = ugettext_lazy('Notification email')
def __str__(self):
"""
Returns the string representation of the Subscription.
:return: the unicode representation
:rtype: unicode
"""
return 'Subscription of "{product!s}" for {user!s}'.format(**{
'product': self.product.title,
'user': self.owner.username,
})
class Meta(object):
"""Meta stuff - you know what..."""
app_label = 'price_monitor'
verbose_name = ugettext_lazy('Subscription')
verbose_name_plural = ugettext_lazy('Subscriptions')
ordering = ('product__title', 'price_limit', 'email_notification__email',)
================================================
FILE: price_monitor/models/__init__.py
================================================
"""Base module for models that are in module entities. Sets all signal handlers"""
import os
from django.db.models.signals import (
post_delete,
post_save,
)
from django.dispatch import receiver
from price_monitor.models.EmailNotification import EmailNotification # noqa
from price_monitor.models.Price import Price # noqa
from price_monitor.models.Product import Product # noqa
from price_monitor.models.Subscription import Subscription # noqa
@receiver(post_save, sender=Product)
def synchronize_product_after_creation(sender, instance, created, **kwargs): # pylint:disable=unused-argument
"""
Directly start synchronization of a Product after its creation.
:param sender: class calling the signal
:type sender: ModelBase
:param instance: the Product instance
:type instance: Product
:param created: if the Product was created
:type created: bool
:param kwargs: additional keywords arguments, see https://docs.djangoproject.com/en/dev/ref/signals/#django.db.models.signals.post_save
:type kwargs: dict
"""
if created and os.environ.get('STAGE', 'Live') != 'TravisCI':
from price_monitor.product_advertising_api.tasks import SynchronizeProductsTask
# have to delay the creation a when using angular via API somehow the product is not fully saved when the task is run thus it does not find the product
SynchronizeProductsTask.apply_async(([instance.asin],), countdown=1)
@receiver(post_delete, sender=Subscription)
def cleanup_products_after_subscription_removal(sender, instance, using, **kwargs): # pylint:disable=unused-argument
"""
Queues the execution of the ProductCleanupTask after a subscription was deleted.
:param sender: class calling the signal
:type sender: ModelBase
:param instance: the Subscription instance
:type instance: price_monitor.models.Subscription
:param using: database alias being used
:type using: str
:param kwargs: additional keywords arguments, see https://docs.djangoproject.com/en/dev/ref/signals/#django.db.models.signals.post_delete
:type kwargs: dict
"""
if os.environ.get('STAGE', 'Live') != 'TravisCI':
from price_monitor.tasks import ProductCleanupTask
ProductCleanupTask.delay(instance.product.asin)
================================================
FILE: price_monitor/models/mixins/PublicIDMixin.py
================================================
"""Mixin for having a public id."""
from django.db import models
from django.utils.translation import ugettext as _
from uuid import uuid4
class PublicIDMixin(models.Model):
"""Mixin for adding a public id to models to prevent revealing database ids via API"""
public_id = models.CharField(
max_length=36,
unique=True,
editable=False,
null=False,
db_index=True,
verbose_name=_('Public-ID')
)
def save(self, *args, **kwargs):
"""
Sets public id on new instances
:param args: positional arguments
:type args: list
:param kwargs: keyword arguments
:type kwargs: dict
:returns: what parent returns
:rtype: see parent
"""
if self.pk is None:
self.public_id = str(uuid4())
return super(PublicIDMixin, self).save(*args, **kwargs)
class Meta(object):
"""Meta stuff"""
abstract = True
app_label = 'price_monitor'
================================================
FILE: price_monitor/models/mixins/__init__.py
================================================
================================================
FILE: price_monitor/product_advertising_api/__init__.py
================================================
================================================
FILE: price_monitor/product_advertising_api/api.py
================================================
import bottlenose
import logging
import random
import time
from bs4 import BeautifulSoup
from dateutil import parser
from price_monitor import (
app_settings,
utils,
)
from urllib.error import HTTPError
logger = logging.getLogger('price_monitor.product_advertising_api')
class ProductAdvertisingAPI(object):
"""
A wrapper class for the necessary Amazon Product Advertising API calls.
See the API reference here: http://docs.aws.amazon.com/AWSECommerceService/latest/DG/CHAP_ApiReference.html
See bottlenose here: https://github.com/lionheart/bottlenose
"""
def __init__(self):
self.__amazon = bottlenose.Amazon(
AWSAccessKeyId=app_settings.PRICE_MONITOR_AWS_ACCESS_KEY_ID,
AWSSecretAccessKey=app_settings.PRICE_MONITOR_AWS_SECRET_ACCESS_KEY,
AssociateTag=app_settings.PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG,
Region=app_settings.PRICE_MONITOR_AMAZON_PRODUCT_API_REGION,
Parser=lambda response_text: BeautifulSoup(response_text, 'lxml'),
ErrorHandler=ProductAdvertisingAPI.handle_error,
)
@staticmethod
def __get_item_attribute(item, attribute):
"""
Returns the attribute value from a bs4 parsed item.
:param item: bs4 item returned from PA API upon item lookup
:param attribute: the attribute to search for
:return: the value if found, else None
:rtype: basestring
"""
value = item.itemattributes.find_all(attribute, recursive=False)
return value[0].string if len(value) == 1 else None
@staticmethod
def format_datetime(value):
"""
Formats the given value if it is not None in the given format.
:param value: the value to format
:type value: basestring
:return: formatted datetime
:rtype: basestring
"""
if value is not None:
try:
return parser.parse(value)
except ValueError:
logger.error('Unable to parse %s to a datetime', value)
return None
@staticmethod
def handle_error(error):
"""
Generic error handler for bottlenose requests.
@see https://github.com/lionheart/bottlenose#error-handling
:param error: error information
:type error: dict
:return: if to retry the request
:rtype: bool
:
"""
ex = error['exception']
logger.error('Error upon requesting Amazon URL %s (Code: %s, Cache-URL: %s): %r', error['api_url'], error['cache_url'], ex, ex.code)
# try reconnect
if isinstance(ex, HTTPError) and ex.code == 503:
time.sleep(random.expovariate(0.1))
return True
return False
def lookup_at_amazon(self, item_ids):
"""
Outsourced this call to better mock in tests.
:param item_ids: the item ids
:type item_ids: list
:return: parsed xml
:rtype: bs4.BeautifulSoup
"""
return self.__amazon.ItemLookup(ItemId=','.join(item_ids), ResponseGroup=app_settings.PRICE_MONITOR_PA_RESPONSE_GROUP)
def item_lookup(self, item_ids):
"""
Lookup of the item with the given id on Amazon. Returns it values or None if something went wrong.
:param item_ids: the item ids
:type item_ids: list
:return: the values of the item
:rtype: dict
"""
logger.info('starting lookup for ASINs %s', ', '.join(item_ids))
item_response = self.lookup_at_amazon(item_ids)
if getattr(item_response, 'items') is None:
logger.error(
'Request for item lookup (ResponseGroup: %s, ASINs: %s) returned nothing',
app_settings.PRICE_MONITOR_PA_RESPONSE_GROUP,
', '.join(item_ids),
)
return dict()
if item_response.items.request.isvalid.string == 'True':
# the dict that will contain a key for every ASIN and as value the parsed values
product_values = dict()
for item_node in item_response.find_all(['item']):
# parse the values
try:
isbn = self.__get_item_attribute(item_node, 'isbn')
eisbn = self.__get_item_attribute(item_node, 'eisbn')
if eisbn is None and isbn is not None:
if len(isbn) == 13:
eisbn = isbn
isbn = None
item_values = {
'asin': item_node.asin.string,
'title': item_node.itemattributes.title.string,
'artist': item_node.itemattributes.artist.string if item_node.itemattributes.artist is not None else None,
'isbn': isbn,
'eisbn': eisbn,
'binding': item_node.itemattributes.binding.string,
'date_publication': self.format_datetime(self.__get_item_attribute(item_node, 'publicationdate')),
'date_release': self.format_datetime(self.__get_item_attribute(item_node, 'releasedate')),
'large_image_url': item_node.largeimage.url.string if item_node.largeimage.url is not None else None,
'medium_image_url': item_node.mediumimage.url.string if item_node.mediumimage.url is not None else None,
'small_image_url': item_node.smallimage.url.string if item_node.smallimage.url is not None else None,
'offer_url': utils.get_offer_url(item_node.asin.string),
'audience_rating': self.__get_item_attribute(item_node, 'audiencerating'),
}
# check if there are offers, if so add price
if item_node.offers is not None and int(item_node.offers.totaloffers.string) > 0:
item_values['price'] = float(int(item_node.offers.offer.offerlisting.price.amount.string) / 100)
item_values['currency'] = item_node.offers.offer.offerlisting.price.currencycode.string
# insert into main dict
product_values[item_values['asin']] = item_values
except AttributeError:
raise
logger.error('fetching item values from returned XML for ASIN %s failed', item_node.asin)
# check if all ASINs are included, if not write error message to log
failed_asins = []
for asin in item_ids:
if asin not in product_values.keys():
failed_asins.append(asin)
if failed_asins:
logger.error('Lookup for the following ASINs failed: %s', ', '.join(failed_asins))
# if there is at least a single ASIN in the list, return the list, else None
return dict() if len(product_values) == 0 else product_values
else:
logger.error(
'Request for item lookup (ResponseGroup: %s, ASINs: %s) was not valid',
app_settings.PRICE_MONITOR_PA_RESPONSE_GROUP,
', '.join(item_ids),
)
return dict()
================================================
FILE: price_monitor/product_advertising_api/tasks.py
================================================
# pylint: disable=unused-argument, arguments-differ
"""Celery tasks for the Amazon Product Advertising API"""
import logging
from celery import chord
from celery.signals import celeryd_after_setup
from celery.task import (
PeriodicTask,
Task,
)
from celery.task.control import (
inspect,
revoke,
)
from collections import (
Counter,
namedtuple,
)
from datetime import (
datetime,
timedelta,
)
from django.db.models import (
Min,
Q,
)
from django.utils import timezone
# from django.utils.translation import ugettext
from price_monitor import app_settings
from price_monitor.models import (
Price,
Product,
Subscription,
)
from price_monitor.product_advertising_api.api import ProductAdvertisingAPI
from price_monitor.utils import (
chunk_list,
send_mail,
)
from smtplib import SMTPServerDisconnected
logger = logging.getLogger('price_monitor.product_advertising_api')
@celeryd_after_setup.connect
def celeryd_after_setup(*args, **kwargs):
"""
Called after the worker instances are set up.
Starts the StartupTask to get the whole synchronization started.
"""
StartupTask().apply_async(countdown=5)
class StartupTask(Task):
"""The task for getting the machinery up and running. As we do not use celery beat, we have to start somewhere."""
ignore_result = True
def run(self):
logger.info('StartupTask was called')
# that's better than an simple tuple
task_repr = namedtuple('TaskRepresentation', 'id, name')
# fetch all currently queued task, map them to the TaskRepresentation tuple
scheduled_tasks = [task_repr(x['request']['id'], x['request']['name']) for x in list(inspect().scheduled().values())[0]]
# count how many FindProductsToSynchronizeTask are scheduled
count = dict(Counter([x.name for x in scheduled_tasks]).most_common())
# check if the FindProductsToSynchronizeTask is in and how often
if count and FindProductsToSynchronizeTask.name in count:
c = count[FindProductsToSynchronizeTask.name]
else:
c = 0
# if the task is not scheduled, do so
if c == 0:
logger.info('no FindProductsToSynchronizeTask is scheduled, now scheduling it')
FindProductsToSynchronizeTask().apply_async(countdown=5)
# put out logging info if the task is already scheduled
if c == 1:
logger.info('FindProductsToSynchronizeTask is already scheduled, skipping additional run')
# if the task is there more than once, remove it
# this has the potential to remove ALL scheduled FindProductsToSynchronizeTasks if the timing is "bad"
# however, the JumpStartTask will re-schedule the task if this happens (a workaround for a workaround - bad design by me btw.)
if c > 1:
logger.info('FindProductsToSynchronizeTask is already scheduled %d times, revoking %d', c, c - 1)
# revoke c-1 tasks - that means the task still stays in schedule but is removed and not executed when execution time is reached
for t in scheduled_tasks[1:]:
logger.info('revoking FindProductsToSynchronizeTask with id %s', t.id)
revoke(t.id)
class JumpStartTask(PeriodicTask):
"""
Task providing jump start to the FindProductsToSynchronizeTask.
It can happen that the FindProductsToSynchronizeTask does not reschedule itself. I don't know why. We do a workaround with this periodic tasks providing a
jumper cable to reschedule the task by queuing the StartupTask.
"""
run_every = timedelta(minutes=60)
def run(self):
"""
Does no more that to call the StartupTask in 5 seconds.
:return: always True
"""
logger.info('JumpStartTask was called')
StartupTask().apply_async(countdown=5)
return True
class FindProductsToSynchronizeTask(Task):
"""The tasks that finds the products that shall be updated through the api."""
def run(self):
"""
Fetches the products to update via api. Queues a SynchronizeProductsTask and calls a new instance of itself after all
tasks are done. If no products found for update, sleeps until the next update time is reached.
:return: the result is always true
:rtype: bool
"""
logger.info('FindProductsToSynchronizeTask was called')
# get all products that shall be updated
products = self.__get_products_to_sync()
if products:
# chunk the products into 10 products each
products_chunked = list(chunk_list(list(products), 10))
logger.info('Starting chord for synchronization of %d products in %d chunks', len(products), len(products_chunked))
# after all single product synchronize tasks are done recall the FindProductsToSynchronizeTask. That is because we do not know how long it takes to
# synchronize the products and there can be new ones meanwhile. If the newly called task finds no products, it will handle the new callback to the
# correct time.
chord(
SynchronizeProductsTask().s([product.asin for product in product_list]) for product_list in products_chunked
)(
FindProductsToSynchronizeTask().si()
)
else:
logger.info('No products found to update now')
# One might think this may interfere with newly created products and their synchronization if they are added before the
# FindProductsToSynchronizeTask is called again, but it doesn't. The new product is updated on creation and the next synchronization is always
# after the next task call.
oldest_synchronization = Product.objects.filter(subscription__isnull=False, status__in=[0, 1]).aggregate(
Min('date_last_synced')
)['date_last_synced__min'] or datetime.now()
next_synchronization = oldest_synchronization + timedelta(minutes=app_settings.PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES)
logger.info('Eta for next FindProductsToSynchronizeTask run is %s', next_synchronization)
FindProductsToSynchronizeTask().apply_async(eta=next_synchronization)
return True
def __get_products_to_sync(self):
"""
Returns the products to synchronize.
These are newly created products with status "0" or products that are older than settings.PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES.
:return: list of products
:rtype: django.db.models.query.QuerySet
"""
# prefer already synced products over newly created
return Product.objects.select_related().filter(
subscription__isnull=False,
date_last_synced__lte=(timezone.now() - timedelta(minutes=app_settings.PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES)),
# issue #21 don't sync products that are not existent
status__in=[0, 1]
)
class SynchronizeProductsTask(Task):
"""Task for synchronizing a single product."""
# limit to one task per second, limited by Amazon API
rate_limit = '1/s'
# if we use the product instances instead of asins we get an EncodeError(RuntimeError('maximum recursion depth exceeded',),) resulting in a
# billiard.exceptions.WorkerLostError:
def run(self, asin_list):
"""
Called by celery if task is being delayed.
:param asin_list: list of asins of the products to be sycnhronized with Amazon
:type asin_list: list
"""
products = dict()
# fetch the product instances
for asin in asin_list:
try:
# do select_related for price values for reducing db queries
product = Product.objects.select_related('highest_price', 'lowest_price', 'current_price').get(asin=asin)
except Product.DoesNotExist:
logger.error('Product with ASIN %s could not be found.', asin)
continue
products[asin] = product
if not products:
logger.error('For the given ASINs %s no products where found!', ','.join(asin_list))
return True
logger.info('Synchronizing products with ItemIds %s', ', '.join(products.keys()))
# query Amazon and iterate over results to update values
for asin, amazon_data in ProductAdvertisingAPI().item_lookup(item_ids=list(products.keys())).items():
self.__sync_product(products[asin], amazon_data)
return True
def __sync_product(self, product, amazon_data):
"""
Synchronizes the given price_monitor.model.Product with the Amazon data.
:param product: the product to update
:type product: price_monitor.models.Product
:param amazon_data: the date from the amazon api
:type amazon_data: dict
"""
now = timezone.now()
# create the price
price = Price.objects.create(
value=amazon_data['price'] if 'price' in amazon_data else None,
currency=amazon_data['currency'] if 'currency' in amazon_data else None,
date_seen=now,
product=product,
)
product.current_price = price
if product.lowest_price is None or (price.value is not None and price.value <= product.lowest_price.value):
product.lowest_price = price
if product.highest_price is None or (price.value is not None and price.value >= product.highest_price.value):
product.highest_price = price
# remove the elements that are not a field in Product model
if 'price' in amazon_data:
amazon_data.pop('price')
if 'currency' in amazon_data:
amazon_data.pop('currency')
# update and save the product
product.__dict__.update(amazon_data)
product.status = 1
product.date_last_synced = now
product.save()
if price.value is not None:
# get all subscriptions of product that are subscribed to the current price or a higher one and
# whose owners have not been notified about that particular subscription price since before
# settings.PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES.
for sub in Subscription.objects.filter(
Q(
product=product,
price_limit__gte=price.value,
date_last_notification__lte=(timezone.now() - timedelta(minutes=app_settings.PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES))
) | Q(
product=product,
price_limit__gte=price.value,
date_last_notification__isnull=True
)
):
# FIXME: how to handle failed notifications?
NotifySubscriberTask().apply_async((product.pk, price.pk, sub.pk), countdown=5)
class NotifySubscriberTask(Task):
"""Task for notifying a single user about a product that has reached the desired price."""
def run(self, product_pk, price_pk, subscription_pk, **kwargs):
"""
Sends an email to the subscriber.
:param product_pk: the id of product to notify about
:type product_pk: int
:param price_pk: the id of current price of the product
:type price_pk: int
:param subscription_pk: the id of Subscription class connecting subscriber and product
:type subscription_pk: int
"""
try:
product = Product.objects.get(pk=product_pk)
except Product.DoesNotExist:
logger.error('Product with PK %d could not be found.', product_pk)
return False
try:
price = Price.objects.get(pk=price_pk)
except Price.DoesNotExist:
logger.error('Price with PK %d could not be found.', price_pk)
return False
try:
subscription = Subscription.objects.get(pk=subscription_pk)
except Subscription.DoesNotExist:
logger.error('Subscription with PK %d could not be found.', subscription_pk)
return False
logger.info('Trying to send notification email to %s...', subscription.email_notification.email)
try:
send_mail(product, subscription, price, self.get_audience_rating_info(product))
except SMTPServerDisconnected:
logger.exception('SMTP server was disconnected.')
else:
logger.info('Notification email to %s was sent!', subscription.email_notification.email)
subscription.date_last_notification = timezone.now()
subscription.save()
return True
return False
# TODO move to Product
def get_audience_rating_info(self, product):
"""
Checks, if the product matches specific audience rating and includes additional information.
If the region is DE and the product is a FSK 18 one, additionally get all other FSK 18 products and put them into a mailable list.
see https://github.com/ponyriders/django-amazon-price-monitor/issues/92
As we do not currently have any use cases that could be generalized to something using the audience rating this is a country specific implementation.
:param product: the product to check
:type product: price_monitor.models.Product
:return: an additional mail text or empty string if product and installation do not match prerequisites.
:rtype: str
"""
# age_identifiers = ['Freigegeben ab 18 Jahren', 'Ages 18 and over']
# if app_settings.PRICE_MONITOR_AMAZON_PRODUCT_API_REGION == 'DE' and product.audience_rating in age_identifiers:
# # mail text
# mail_text = ''
#
# # fetch all other products with FSK 18
# for p in Product.objects.filter(audience_rating__in=age_identifiers).exclude(pk=product.pk).order_by('current_price'):
# mail_text += '{title:s}\n'.format(title=p.get_title())
# mail_text += '{price:0.2f} {currency:s} ({price_date:s})\n'.format(
# price=p.current_price.value,
# currency=p.current_price.currency,
# price_date=p.current_price.date_seen.strftime('%b %d, %Y %H:%M %p %Z'),
# )
# mail_text += '{offer_url:s}\n'.format(offer_url=p.offer_url)
# mail_text += '{product_detail_url:s}\n\n'.format(product_detail_url=product.get_detail_url())
#
# # prepend introduction if there were results
# if mail_text:
# mail_text = '\n{intro:s}\n\n'.format(
# intro=ugettext('As this is a FSK 18 article, here are your other subscribed FSK 18 articles:')
# ) + mail_text
#
# # return
# return mail_text
return ''
================================================
FILE: price_monitor/static/price_monitor/angular/angular-django-rest-resource.js
================================================
'use strict';
//Portions of this file:
//Copyright (c) 2010-2012 Google, Inc. http://angularjs.org
//Those portions modified, used, or copied under permissions granted by the MIT license. See:
// https://raw.github.com/angular/angular.js/9480136d9f062ec4b8df0a35914b48c0d61e0002/LICENSE
/**
* @ngdoc overview
* @name djangoRESTResources
* @description
*/
/**
* @ngdoc object
* @name djangoRESTResources.djResource
* @requires $http
*
* @description
* A factory for generating classes that interact with a Django REST Framework backend.
*
* Identical in operation to AngularJS' ngResource module's $resource object except for the following:
* - If an isArray=True request receives a JSON _object_ containing a `count` field (instead of a JS array), assume
* that the REST endpoint has `paginate_by` set. The results are then streamed a page at a time into the promise object
* and any success callbacks are deferred until the last page returns successfully.
* - URLs are assumed to have the trailing slashes, as is the Django way of doing things.
*
* # Installation
* Include `angular-django-rest-resource.js`
*
* Load the module:
*
* angular.module('app', ['djangoRESTResources']);
*
* now you inject djResource into any of your Angular things.
*
* @param {string} url A parametrized URL template with parameters prefixed by `:` as in
* `/user/:username`. If you are using a URL with a port number (e.g.
* `http://example.com:8080/api`), you'll need to escape the colon character before the port
* number, like this: `djResource('http://example.com\\:8080/api')`.
*
* @param {Object=} paramDefaults Default values for `url` parameters. These can be overridden in
* `actions` methods. If any of the parameter value is a function, it will be executed every time
* when a param value needs to be obtained for a request (unless the param was overridden).
*
* Each key value in the parameter object is first bound to url template if present and then any
* excess keys are appended to the url search query after the `?`.
*
* Given a template `/path/:verb` and parameter `{verb:'greet', salutation:'Hello'}` results in
* URL `/path/greet?salutation=Hello`.
*
* If the parameter value is prefixed with `@` then the value of that parameter is extracted from
* the data object (useful for non-GET operations).
*
* @param {Object.<Object>=} actions Hash with declaration of custom action that should extend the
* default set of resource actions. The declaration should be created in the format of $http.config
*
* {action1: {method:?, params:?, isArray:?, headers:?, ...},
* action2: {method:?, params:?, isArray:?, headers:?, ...},
* ...}
*
* Where:
*
* - **`action`** – {string} – The name of action. This name becomes the name of the method on your
* resource object.
* - **`method`** – {string} – HTTP request method. Valid methods are: `GET`, `POST`, `PUT`, `DELETE`,
* and `JSONP`.
* - **`params`** – {Object=} – Optional set of pre-bound parameters for this action. If any of the
* parameter value is a function, it will be executed every time when a param value needs to be
* obtained for a request (unless the param was overridden).
* - **`url`** – {string} – action specific `url` override. The url templating is supported just like
* for the resource-level urls.
* - **`isArray`** – {boolean=} – If true then the returned object for this action is an array, see
* `returns` section.
* - **`transformRequest`** – `{function(data, headersGetter)|Array.<function(data, headersGetter)>}` –
* transform function or an array of such functions. The transform function takes the http
* request body and headers and returns its transformed (typically serialized) version.
* - **`transformResponse`** – `{function(data, headersGetter)|Array.<function(data, headersGetter)>}` –
* transform function or an array of such functions. The transform function takes the http
* response body and headers and returns its transformed (typically deserialized) version.
* - **`cache`** – `{boolean|Cache}` – If true, a default $http cache will be used to cache the
* GET request, otherwise if a cache instance built with
* {@link http://docs.angularjs.org/api/ng.$cacheFactory $cacheFactory}, this cache will be used for
* caching.
* - **`timeout`** – `{number}` – timeout in milliseconds.
* - **`withCredentials`** - `{boolean}` - whether to to set the `withCredentials` flag on the
* XHR object. See {@link https://developer.mozilla.org/en/http_access_control#section_5
* requests with credentials} for more information.
* - **`responseType`** - `{string}` - see
* {@link https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#responseType requestType}.
*
* @returns {Object} A resource "class" object with methods for the default set of resource actions
* optionally extended with custom `actions`. The default set contains these actions:
*
* { 'get': {method:'GET'},
* 'save': {method:'POST', method_if_field_has_value:['id', 'PUT']},
* 'update': {method:'PUT'},
* 'query': {method:'GET', isArray:true},
* 'remove': {method:'DELETE'},
* 'delete': {method:'DELETE'} };
*
* Calling these methods invoke an {@link http://docs.angularjs.org/api/ng.$http $http} with the specified http
* method, destination and parameters. When the data is returned from the server then the object is an
* instance of the resource class. The actions `save`, `remove` and `delete` are available on it
* as methods with the `$` prefix. This allows you to easily perform CRUD operations (create,
* read, update, delete
gitextract_h5nbuqa_/ ├── .coveragerc ├── .editorconfig ├── .gitignore ├── .landscape.yaml ├── .travis.yml ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docker/ │ ├── .gitignore │ ├── base/ │ │ └── Dockerfile │ ├── compose.env │ ├── docker-compose.yml │ └── web/ │ ├── Dockerfile │ ├── celery_run.sh │ ├── django-amazon-price-monitor/ │ │ ├── price_monitor/ │ │ │ └── __init__.py │ │ └── setup.py │ ├── project/ │ │ ├── glue/ │ │ │ ├── __init__.py │ │ │ ├── celery.py │ │ │ ├── settings.py │ │ │ ├── urls.py │ │ │ └── wsgi.py │ │ ├── glue_auth/ │ │ │ ├── __init__.py │ │ │ ├── fixtures/ │ │ │ │ └── admin.json │ │ │ ├── models.py │ │ │ ├── templates/ │ │ │ │ ├── glue_auth/ │ │ │ │ │ ├── base.html │ │ │ │ │ └── login.html │ │ │ │ └── price_monitor/ │ │ │ │ └── angular_index_view.html │ │ │ └── urls.py │ │ ├── manage.py │ │ └── requirements.pip │ └── web_run.sh ├── docs/ │ └── price_monitor.product_advertising_api.tasks.activity.violet.html ├── hooks/ │ └── pre-commit ├── price_monitor/ │ ├── __init__.py │ ├── admin.py │ ├── api/ │ │ ├── __init__.py │ │ ├── renderers/ │ │ │ ├── PriceChartPNGRenderer.py │ │ │ └── __init__.py │ │ ├── serializers/ │ │ │ ├── EmailNotificationSerializer.py │ │ │ ├── PriceSerializer.py │ │ │ ├── ProductSerializer.py │ │ │ ├── SubscriptionSerializer.py │ │ │ └── __init__.py │ │ ├── urls.py │ │ └── views/ │ │ ├── EmailNotificationListView.py │ │ ├── PriceListView.py │ │ ├── ProductCreateRetrieveUpdateDestroyAPIView.py │ │ ├── ProductListView.py │ │ ├── SubscriptionListView.py │ │ ├── SubscriptionRetrieveView.py │ │ ├── __init__.py │ │ └── mixins/ │ │ ├── ProductFilteringMixin.py │ │ └── __init__.py │ ├── app_settings.py │ ├── forms.py │ ├── locale/ │ │ └── de/ │ │ └── LC_MESSAGES/ │ │ ├── django.mo │ │ └── django.po │ ├── management/ │ │ ├── __init__.py │ │ └── commands/ │ │ ├── __init__.py │ │ ├── price_monitor_batch_create_products.py │ │ ├── price_monitor_clean_db.py │ │ ├── price_monitor_recreate_product.py │ │ ├── price_monitor_search.py │ │ └── price_monitor_send_test_mail.py │ ├── migrations/ │ │ ├── 0001_initial.py │ │ ├── 0002_add_min_max_fk_to_product.py │ │ ├── 0003_datamigration_for_min_max_cur_fks.py │ │ ├── 0004_make_price_and_currency_nullable.py │ │ ├── 0005_product_artist.py │ │ └── __init__.py │ ├── models/ │ │ ├── EmailNotification.py │ │ ├── Price.py │ │ ├── Product.py │ │ ├── Subscription.py │ │ ├── __init__.py │ │ └── mixins/ │ │ ├── PublicIDMixin.py │ │ └── __init__.py │ ├── product_advertising_api/ │ │ ├── __init__.py │ │ ├── api.py │ │ └── tasks.py │ ├── static/ │ │ └── price_monitor/ │ │ ├── angular/ │ │ │ ├── angular-django-rest-resource.js │ │ │ └── angular-responsive-images.js │ │ ├── app/ │ │ │ ├── css/ │ │ │ │ ├── app.css │ │ │ │ └── xeditable.css │ │ │ ├── js/ │ │ │ │ ├── app.js │ │ │ │ ├── controller/ │ │ │ │ │ ├── emailnotification-create-ctrl.js │ │ │ │ │ ├── main-ctrl.js │ │ │ │ │ ├── product-delete-ctrl.js │ │ │ │ │ ├── product-detail-ctrl.js │ │ │ │ │ └── product-list-ctrl.js │ │ │ │ ├── filters.js │ │ │ │ └── server-connector.js │ │ │ └── partials/ │ │ │ ├── emailnotification-create.html │ │ │ ├── product-delete.html │ │ │ ├── product-detail.html │ │ │ └── product-list.html │ │ ├── bootstrap/ │ │ │ └── css/ │ │ │ ├── bootstrap-theme.css │ │ │ └── bootstrap.css │ │ └── css/ │ │ ├── base.css │ │ └── inline-form.css │ ├── tasks.py │ ├── templates/ │ │ └── price_monitor/ │ │ └── angular_index_view.html │ ├── urls.py │ ├── utils.py │ └── views.py ├── setup.cfg ├── setup.py ├── tests/ │ ├── __init__.py │ ├── product_advertising_api/ │ │ ├── __init__.py │ │ ├── data.py │ │ └── test_api.py │ ├── settings.py │ ├── test_product.py │ └── test_utils.py └── tox.ini
SYMBOL INDEX (157 symbols across 42 files)
FILE: docker/web/django-amazon-price-monitor/price_monitor/__init__.py
function get_version (line 4) | def get_version():
FILE: price_monitor/__init__.py
function get_version (line 13) | def get_version(short=False):
FILE: price_monitor/admin.py
class PriceAdmin (line 13) | class PriceAdmin(admin.ModelAdmin):
class ProductAdmin (line 21) | class ProductAdmin(admin.ModelAdmin):
method reset_to_created (line 32) | def reset_to_created(self, request, queryset): # pylint:disable=unuse...
method resynchronize (line 41) | def resynchronize(self, request, queryset): # pylint:disable=unused-a...
class SubscriptionAdmin (line 53) | class SubscriptionAdmin(admin.ModelAdmin):
class EmailNotificationAdmin (line 61) | class EmailNotificationAdmin(admin.ModelAdmin):
FILE: price_monitor/api/renderers/PriceChartPNGRenderer.py
function bool_helper (line 18) | def bool_helper(x):
class PriceChartPNGRenderer (line 28) | class PriceChartPNGRenderer(BaseRenderer):
method render (line 55) | def render(self, data, accepted_media_type=None, renderer_context=None...
method sanitize_allowed_args (line 90) | def sanitize_allowed_args(self, request):
method create_cache_key (line 109) | def create_cache_key(self, data, args):
method create_graph (line 115) | def create_graph(self, data, args):
FILE: price_monitor/api/serializers/EmailNotificationSerializer.py
class EmailNotificationSerializer (line 7) | class EmailNotificationSerializer(serializers.ModelSerializer):
class Meta (line 15) | class Meta(object):
FILE: price_monitor/api/serializers/PriceSerializer.py
class PriceSerializer (line 7) | class PriceSerializer(serializers.ModelSerializer):
class Meta (line 11) | class Meta(object):
FILE: price_monitor/api/serializers/ProductSerializer.py
class ProductSerializer (line 10) | class ProductSerializer(serializers.ModelSerializer):
method __render_price_dict (line 26) | def __render_price_dict(self, price):
method get_current_price (line 41) | def get_current_price(self, obj):
method get_highest_price (line 53) | def get_highest_price(self, obj):
method get_lowest_price (line 65) | def get_lowest_price(self, obj):
method get_image_urls (line 77) | def get_image_urls(self, obj):
method create (line 89) | def create(self, validated_data):
method update (line 119) | def update(self, instance, validated_data):
class Meta (line 155) | class Meta(object):
FILE: price_monitor/api/serializers/SubscriptionSerializer.py
class SubscriptionSerializer (line 8) | class SubscriptionSerializer(serializers.ModelSerializer):
class Meta (line 16) | class Meta(object):
FILE: price_monitor/api/views/EmailNotificationListView.py
class EmailNotificationListView (line 8) | class EmailNotificationListView(mixins.CreateModelMixin, generics.ListAP...
method post (line 19) | def post(self, request, *args, **kwargs):
method get_queryset (line 30) | def get_queryset(self):
FILE: price_monitor/api/views/PriceListView.py
class PriceListView (line 13) | class PriceListView(ListAPIView):
method get_queryset (line 18) | def get_queryset(self):
FILE: price_monitor/api/views/ProductCreateRetrieveUpdateDestroyAPIView.py
class ProductCreateRetrieveUpdateDestroyAPIView (line 9) | class ProductCreateRetrieveUpdateDestroyAPIView(ProductFilteringMixin, m...
method post (line 21) | def post(self, request, *args, **kwargs):
method get_queryset (line 32) | def get_queryset(self):
method perform_destroy (line 42) | def perform_destroy(self, instance):
FILE: price_monitor/api/views/ProductListView.py
class ProductListView (line 9) | class ProductListView(ProductFilteringMixin, generics.ListAPIView):
FILE: price_monitor/api/views/SubscriptionListView.py
class SubscriptionListView (line 8) | class SubscriptionListView(generics.ListAPIView):
method get_queryset (line 21) | def get_queryset(self):
FILE: price_monitor/api/views/SubscriptionRetrieveView.py
class SubscriptionRetrieveView (line 8) | class SubscriptionRetrieveView(generics.RetrieveAPIView):
method get_queryset (line 20) | def get_queryset(self):
FILE: price_monitor/api/views/mixins/ProductFilteringMixin.py
class ProductFilteringMixin (line 7) | class ProductFilteringMixin(object):
method filter_queryset (line 11) | def filter_queryset(self, queryset):
FILE: price_monitor/forms.py
class SubscriptionCreationForm (line 11) | class SubscriptionCreationForm(forms.ModelForm):
method clean_product (line 18) | def clean_product(self):
class Meta (line 32) | class Meta(object):
class SubscriptionUpdateForm (line 43) | class SubscriptionUpdateForm(forms.ModelForm):
class Meta (line 47) | class Meta(object):
class EmailNotificationForm (line 59) | class EmailNotificationForm(forms.ModelForm):
class Meta (line 63) | class Meta(object):
FILE: price_monitor/management/commands/price_monitor_batch_create_products.py
class Command (line 7) | class Command(BaseCommand):
method add_arguments (line 13) | def add_arguments(self, parser):
method handle (line 21) | def handle(self, *args, **options):
FILE: price_monitor/management/commands/price_monitor_clean_db.py
class Command (line 10) | class Command(BaseCommand):
method handle (line 16) | def handle(self, *args, **options):
FILE: price_monitor/management/commands/price_monitor_recreate_product.py
class Command (line 7) | class Command(BaseCommand):
method add_arguments (line 10) | def add_arguments(self, parser):
method handle (line 18) | def handle(self, *args, **options):
FILE: price_monitor/management/commands/price_monitor_search.py
class Command (line 9) | class Command(BaseCommand):
method add_arguments (line 15) | def add_arguments(self, parser):
method handle (line 23) | def handle(self, *args, **options):
FILE: price_monitor/management/commands/price_monitor_send_test_mail.py
class Command (line 16) | class Command(BaseCommand):
method add_arguments (line 22) | def add_arguments(self, parser):
method handle (line 30) | def handle(self, *args, **options):
FILE: price_monitor/migrations/0001_initial.py
class Migration (line 8) | class Migration(migrations.Migration):
FILE: price_monitor/migrations/0002_add_min_max_fk_to_product.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: price_monitor/migrations/0003_datamigration_for_min_max_cur_fks.py
function set_prices (line 7) | def set_prices(apps, schema_editor):
class Migration (line 19) | class Migration(migrations.Migration):
FILE: price_monitor/migrations/0004_make_price_and_currency_nullable.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: price_monitor/migrations/0005_product_artist.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: price_monitor/models/EmailNotification.py
class EmailNotification (line 11) | class EmailNotification(PublicIDMixin, models.Model):
method __str__ (line 18) | def __str__(self):
class Meta (line 31) | class Meta(object):
FILE: price_monitor/models/Price.py
class Price (line 6) | class Price(models.Model):
method __str__ (line 15) | def __str__(self):
class Meta (line 28) | class Meta(object):
FILE: price_monitor/models/Product.py
class Product (line 19) | class Product(models.Model):
method get_prices_for_chart (line 60) | def get_prices_for_chart(self):
method set_failed_to_sync (line 70) | def set_failed_to_sync(self):
method get_image_urls (line 75) | def get_image_urls(self):
method __get_image_url (line 89) | def __get_image_url(self, url):
method get_graph_cache_key (line 101) | def get_graph_cache_key(self):
method get_title (line 110) | def get_title(self):
method get_detail_url (line 122) | def get_detail_url(self):
method __str__ (line 134) | def __str__(self):
class Meta (line 143) | class Meta(object):
FILE: price_monitor/models/Subscription.py
class Subscription (line 9) | class Subscription(PublicIDMixin, models.Model):
method get_email_address (line 19) | def get_email_address(self):
method __str__ (line 29) | def __str__(self):
class Meta (line 41) | class Meta(object):
FILE: price_monitor/models/__init__.py
function synchronize_product_after_creation (line 17) | def synchronize_product_after_creation(sender, instance, created, **kwar...
function cleanup_products_after_subscription_removal (line 37) | def cleanup_products_after_subscription_removal(sender, instance, using,...
FILE: price_monitor/models/mixins/PublicIDMixin.py
class PublicIDMixin (line 8) | class PublicIDMixin(models.Model):
method save (line 21) | def save(self, *args, **kwargs):
class Meta (line 36) | class Meta(object):
FILE: price_monitor/product_advertising_api/api.py
class ProductAdvertisingAPI (line 21) | class ProductAdvertisingAPI(object):
method __init__ (line 28) | def __init__(self):
method __get_item_attribute (line 39) | def __get_item_attribute(item, attribute):
method format_datetime (line 51) | def format_datetime(value):
method handle_error (line 67) | def handle_error(error):
method lookup_at_amazon (line 88) | def lookup_at_amazon(self, item_ids):
method item_lookup (line 98) | def item_lookup(self, item_ids):
FILE: price_monitor/product_advertising_api/tasks.py
function celeryd_after_setup (line 53) | def celeryd_after_setup(*args, **kwargs):
class StartupTask (line 62) | class StartupTask(Task):
method run (line 68) | def run(self):
class JumpStartTask (line 107) | class JumpStartTask(PeriodicTask):
method run (line 118) | def run(self):
class FindProductsToSynchronizeTask (line 129) | class FindProductsToSynchronizeTask(Task):
method run (line 133) | def run(self):
method __get_products_to_sync (line 174) | def __get_products_to_sync(self):
class SynchronizeProductsTask (line 191) | class SynchronizeProductsTask(Task):
method run (line 200) | def run(self, asin_list):
method __sync_product (line 231) | def __sync_product(self, product, amazon_data):
class NotifySubscriberTask (line 288) | class NotifySubscriberTask(Task):
method run (line 292) | def run(self, product_pk, price_pk, subscription_pk, **kwargs):
method get_audience_rating_info (line 335) | def get_audience_rating_info(self, product):
FILE: price_monitor/static/price_monitor/angular/angular-django-rest-resource.js
function encodeUriSegment (line 175) | function encodeUriSegment(val) {
function encodeUriQuery (line 194) | function encodeUriQuery(val, pctEncodeSpaces) {
function Route (line 203) | function Route(template, defaults) {
function DjangoRESTResourceFactory (line 256) | function DjangoRESTResourceFactory(url, paramDefaults, actions) {
FILE: price_monitor/static/price_monitor/angular/angular-responsive-images.js
function updateFromQuery (line 47) | function updateFromQuery(querySets) {
function setSrc (line 105) | function setSrc(src) {
FILE: price_monitor/tasks.py
class ProductCleanupTask (line 16) | class ProductCleanupTask(Task):
method run (line 20) | def run(self, asin):
FILE: price_monitor/utils.py
function get_offer_url (line 13) | def get_offer_url(asin):
function get_product_detail_url (line 29) | def get_product_detail_url(asin):
function send_mail (line 43) | def send_mail(product, subscription, price, additional_text=''):
function chunk_list (line 74) | def chunk_list(the_list, chunk_size):
FILE: price_monitor/views.py
class AngularIndexView (line 15) | class AngularIndexView(TemplateView):
method dispatch (line 20) | def dispatch(self, *args, **kwargs):
method get_context_data (line 35) | def get_context_data(self, form=None, **kwargs):
method get (line 46) | def get(self, request, **kwargs):
method post (line 51) | def post(self, request, **kwargs):
FILE: tests/product_advertising_api/test_api.py
class ProductAdvertisingAPITest (line 28) | class ProductAdvertisingAPITest(TestCase):
method __get_product_bs (line 33) | def __get_product_bs(self, xml):
method test_item_lookup_response_fail (line 44) | def test_item_lookup_response_fail(self, papi_init, papi_lookup, lc):
method test_item_lookup_fail (line 70) | def test_item_lookup_fail(self, papi_init, papi_lookup, lc):
method test_item_lookup_no_item (line 95) | def test_item_lookup_no_item(self, papi_init, papi_lookup, lc):
method test_item_lookup_normal (line 120) | def test_item_lookup_normal(self, product_api_init, product_api_lookup...
method test_item_lookup_no_price (line 168) | def test_item_lookup_no_price(self, product_api_init, product_api_look...
method test_item_lookup_no_audience_rating_isbn (line 220) | def test_item_lookup_no_audience_rating_isbn(self, product_api_init, p...
method test_item_lookup_no_offers (line 269) | def test_item_lookup_no_offers(self, product_api_init, product_api_loo...
method test_item_lookup_10_products (line 317) | def test_item_lookup_10_products(self, product_api_init, product_api_l...
method test_item_lookup_3_products (line 538) | def test_item_lookup_3_products(self, product_api_init, product_api_lo...
method test_item_lookup_no_images (line 623) | def test_item_lookup_no_images(self, product_api_init, product_api_loo...
method test_item_lookup_artist (line 671) | def test_item_lookup_artist(self, product_api_init, product_api_lookup...
method test_format_datetime (line 717) | def test_format_datetime(self):
method test_invalid_isbn_value (line 729) | def test_invalid_isbn_value(self, product_api_init, product_api_lookup...
FILE: tests/test_product.py
class ProductTest (line 7) | class ProductTest(TestCase):
method test_set_failed_to_sync (line 9) | def test_set_failed_to_sync(self):
method test_get_image_urls (line 19) | def test_get_image_urls(self):
method test_get_detail_url (line 60) | def test_get_detail_url(self):
FILE: tests/test_utils.py
class UtilsTest (line 7) | class UtilsTest(TestCase):
method test_get_offer_url (line 11) | def test_get_offer_url(self):
method test_chunk_list (line 15) | def test_chunk_list(self):
Condensed preview — 118 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (498K chars).
[
{
"path": ".coveragerc",
"chars": 39,
"preview": "[run]\nomit = price_monitor/migrations/*"
},
{
"path": ".editorconfig",
"chars": 136,
"preview": "[*.rst]\nindent_style = tab\nindent_size = 4\n\n[*.json]\nindent_style = space\nindent_size = 4\n\n[Makefile]\nindent_style = tab"
},
{
"path": ".gitignore",
"chars": 194,
"preview": "*.log\n*.pot\n*.pyc\n.coverage\n.cache\n.env\n.idea\n.project\n.pydevproject\n.settings\n.tox\n.vscode\nbuild\ndist\ndjango_amazon_pri"
},
{
"path": ".landscape.yaml",
"chars": 224,
"preview": "doc-warnings: yes\nmax-line-length: 160\nuses:\n - django\n - celery\nignore-paths:\n - docs\n - hooks\n - price_monitor/mi"
},
{
"path": ".travis.yml",
"chars": 970,
"preview": "language: python\nsudo: false\nmatrix:\n include:\n - python: 3.4\n env:\n - TOXENV=py34-django1.8\n - python: 3.4\n "
},
{
"path": "CONTRIBUTING.rst",
"chars": 1048,
"preview": "Contributing\n============\n\nIf you like to lend us a hand, feel free to contribute code to the project. Pick an issue or "
},
{
"path": "HISTORY.rst",
"chars": 10090,
"preview": "Change Log\n==========\nTBA\n---\n**Maintenance**\n\n- updated the following packages\n\t- ``Celery`` from ``3`` to ``4``, **the"
},
{
"path": "LICENSE",
"chars": 1006,
"preview": "Permission is hereby granted, free of charge, to any person obtaining a\ncopy of this software and associated documentati"
},
{
"path": "MANIFEST.in",
"chars": 136,
"preview": "include README.rst\ninclude HISTORY.rst\nrecursive-include price_monitor *.html *.png *.gif *js *.css *jpg *jpeg *svg *py "
},
{
"path": "Makefile",
"chars": 543,
"preview": "help:\n\t@echo \"docker-build-base: - builds the base docker image (not necessary normally as image is on docker hub)\"\n\t@ec"
},
{
"path": "README.rst",
"chars": 20350,
"preview": "|Build Status| |codecov.io| |Requirements Status| |Stories in Ready| |Landscape|\n\n.. contents:: Table of Contents\n\ndjang"
},
{
"path": "docker/.gitignore",
"chars": 82,
"preview": "docker-compose.override.yml\nlogs\nmedia\npostgres\nweb/project/celerybeat-schedule.db"
},
{
"path": "docker/base/Dockerfile",
"chars": 685,
"preview": "# basic setup\nFROM philcryer/min-jessie\nMAINTAINER Alexander Herrmann <darignac@gmail.com>\n\n# install basic packages: lx"
},
{
"path": "docker/compose.env",
"chars": 271,
"preview": "PYTHONUNBUFFERED=1\nPOSTGRES_USER=pm_user\nPOSTGRES_DB=pm_db\nPOSTGRES_PASSWORD=6i2vmzq5C6BuSf5k33A6tmMSHwKKv0Pu\nDEBUG='Tru"
},
{
"path": "docker/docker-compose.yml",
"chars": 788,
"preview": "version: '3'\nservices:\n # database container\n db:\n image: postgres:9\n env_file: compose.env\n volumes:\n -"
},
{
"path": "docker/web/Dockerfile",
"chars": 792,
"preview": "# basic setup, use base image of treasury project\nFROM pricemonitor/base:latest\nMAINTAINER Alexander Herrmann <darignac@"
},
{
"path": "docker/web/celery_run.sh",
"chars": 63,
"preview": "#!/bin/sh\n# wait for redis\nsleep 5\ncelery --beat -A glue worker"
},
{
"path": "docker/web/django-amazon-price-monitor/price_monitor/__init__.py",
"chars": 336,
"preview": "\"\"\"Dummy init module for the price_monitor package. It will be overwritten when docker mounts the real package.\"\"\"\n\n\ndef"
},
{
"path": "docker/web/django-amazon-price-monitor/setup.py",
"chars": 1290,
"preview": "#!/usr/bin/env python\n\"\"\"Setup file for the django-amazon-price-monitor package.\"\"\"\ntry:\n from setuptools import setu"
},
{
"path": "docker/web/project/glue/__init__.py",
"chars": 219,
"preview": "\"\"\"Glue project init\"\"\"\nfrom __future__ import absolute_import\n\n# This will make sure the app is always imported when\n# "
},
{
"path": "docker/web/project/glue/celery.py",
"chars": 528,
"preview": "\"\"\"Celery setup for the glue project.\"\"\"\nfrom __future__ import absolute_import\n\nimport os\n\nfrom celery import Celery\n\nf"
},
{
"path": "docker/web/project/glue/settings.py",
"chars": 8279,
"preview": "\"\"\"\nDjango settings for glue project.\n\nGenerated by 'django-admin startproject' using Django 1.8.2.\n\nFor more informatio"
},
{
"path": "docker/web/project/glue/urls.py",
"chars": 287,
"preview": "\"\"\"URL definitions for the glue project.\"\"\"\nfrom django.conf.urls import include, url\nfrom django.contrib import admin\n\n"
},
{
"path": "docker/web/project/glue/wsgi.py",
"chars": 422,
"preview": "\"\"\"\nWSGI config for glue project.\n\nIt exposes the WSGI callable as a module-level variable named ``application``.\n\nFor m"
},
{
"path": "docker/web/project/glue_auth/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "docker/web/project/glue_auth/fixtures/admin.json",
"chars": 572,
"preview": "[\n {\n \"model\": \"auth.user\",\n \"pk\": 1,\n \"fields\": {\n \"password\": \"pbkdf2_sha256$24000$"
},
{
"path": "docker/web/project/glue_auth/models.py",
"chars": 0,
"preview": ""
},
{
"path": "docker/web/project/glue_auth/templates/glue_auth/base.html",
"chars": 668,
"preview": "<!DOCTYPE html>{% load static %}\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta http-equiv=\"X-"
},
{
"path": "docker/web/project/glue_auth/templates/glue_auth/login.html",
"chars": 699,
"preview": "{% extends 'glue_auth/base.html' %}\n\n\n{% block content %}\n {% if form.errors %}\n <p>Your username and password"
},
{
"path": "docker/web/project/glue_auth/templates/price_monitor/angular_index_view.html",
"chars": 179,
"preview": "{% extends \"price_monitor/angular_index_view.html\" %}\n\n\n{% block footer %}\n<div class=\"row\">\n <div class=\"col-md-12\">"
},
{
"path": "docker/web/project/glue_auth/urls.py",
"chars": 330,
"preview": "\"\"\"URL definitions for the glue_auth module.\"\"\"\nfrom django.conf.urls import url\nfrom django.contrib.auth.views import l"
},
{
"path": "docker/web/project/manage.py",
"chars": 278,
"preview": "#!/usr/bin/env python\n\"\"\"Main Django entry point.\"\"\"\nimport os\nimport sys\n\nif __name__ == \"__main__\":\n os.environ.set"
},
{
"path": "docker/web/project/requirements.pip",
"chars": 100,
"preview": "Django<2\ndj-database-url\npsycopg2>=2.5.4\ncelery>=4,<5\ndjango-redis-cache>=1.5.4\nhiredis<0.3\nPillow<6"
},
{
"path": "docker/web/web_run.sh",
"chars": 154,
"preview": "#!/bin/sh\n# wait for postgres\nsleep 5\ncd /srv/project/\npython3 manage.py migrate\npython3 manage.py loaddata admin\npython"
},
{
"path": "docs/price_monitor.product_advertising_api.tasks.activity.violet.html",
"chars": 41193,
"preview": "<HTML>\n<HEAD>\n<META name=\"description\"\n\tcontent=\"Violet UML Editor cross format document\" />\n<META name=\"keywords\" conte"
},
{
"path": "hooks/pre-commit",
"chars": 70,
"preview": "#!/bin/sh\nflake8 price_monitor --ignore=E501,E128 --exclude=migrations"
},
{
"path": "price_monitor/__init__.py",
"chars": 692,
"preview": "\"\"\"\ndjango-amazon-price-monitor monitors prices of Amazon products.\n\"\"\"\n__version_info__ = {\n 'major': 0,\n 'minor'"
},
{
"path": "price_monitor/admin.py",
"chars": 2310,
"preview": "\"\"\"AdminSite definitions\"\"\"\nfrom django.contrib import admin\nfrom django.utils.translation import ugettext_lazy\n\nfrom pr"
},
{
"path": "price_monitor/api/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "price_monitor/api/renderers/PriceChartPNGRenderer.py",
"chars": 4915,
"preview": "\"\"\"Module for rendering price charts as PNG\"\"\"\nimport dateutil.parser\nimport hashlib\n\nfrom ... import app_settings\n\nfrom"
},
{
"path": "price_monitor/api/renderers/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "price_monitor/api/serializers/EmailNotificationSerializer.py",
"chars": 512,
"preview": "\"\"\"Serializer for EmailNotification model\"\"\"\nfrom ...models import EmailNotification\n\nfrom rest_framework import seriali"
},
{
"path": "price_monitor/api/serializers/PriceSerializer.py",
"chars": 403,
"preview": "\"\"\"Serializer for Price model\"\"\"\nfrom ...models import Price\n\nfrom rest_framework import serializers\n\n\nclass PriceSerial"
},
{
"path": "price_monitor/api/serializers/ProductSerializer.py",
"chars": 7171,
"preview": "\"\"\"Serializer for Product model\"\"\"\nfrom .SubscriptionSerializer import SubscriptionSerializer\nfrom ...models import Emai"
},
{
"path": "price_monitor/api/serializers/SubscriptionSerializer.py",
"chars": 862,
"preview": "\"\"\"Serializer for Subscription model\"\"\"\nfrom .EmailNotificationSerializer import EmailNotificationSerializer\nfrom ...mod"
},
{
"path": "price_monitor/api/serializers/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "price_monitor/api/urls.py",
"chars": 1198,
"preview": "from django.conf.urls import url\n\nfrom .views.EmailNotificationListView import EmailNotificationListView\nfrom .views.Pri"
},
{
"path": "price_monitor/api/views/EmailNotificationListView.py",
"chars": 1163,
"preview": "\"\"\"View for listing email notifications\"\"\"\nfrom ..serializers.EmailNotificationSerializer import EmailNotificationSerial"
},
{
"path": "price_monitor/api/views/PriceListView.py",
"chars": 987,
"preview": "\"\"\"View for listing prices\"\"\"\nfrom ..renderers.PriceChartPNGRenderer import PriceChartPNGRenderer\nfrom ..serializers.Pri"
},
{
"path": "price_monitor/api/views/ProductCreateRetrieveUpdateDestroyAPIView.py",
"chars": 1699,
"preview": "\"\"\"Mixed view for API\"\"\"\nfrom .mixins.ProductFilteringMixin import ProductFilteringMixin\nfrom ..serializers.ProductSeria"
},
{
"path": "price_monitor/api/views/ProductListView.py",
"chars": 672,
"preview": "\"\"\"View for listing subscriptions\"\"\"\nfrom rest_framework import generics, permissions\n\nfrom .mixins.ProductFilteringMixi"
},
{
"path": "price_monitor/api/views/SubscriptionListView.py",
"chars": 793,
"preview": "\"\"\"View for listing subscriptions\"\"\"\nfrom ..serializers.SubscriptionSerializer import SubscriptionSerializer\nfrom ...mod"
},
{
"path": "price_monitor/api/views/SubscriptionRetrieveView.py",
"chars": 815,
"preview": "\"\"\"View for retrieving a subscription\"\"\"\nfrom ..serializers.SubscriptionSerializer import SubscriptionSerializer\nfrom .."
},
{
"path": "price_monitor/api/views/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "price_monitor/api/views/mixins/ProductFilteringMixin.py",
"chars": 1018,
"preview": "\"\"\"Mixin for product filtering\"\"\"\nfrom django.db.models.query import Prefetch\n\nfrom ....models.Subscription import Subsc"
},
{
"path": "price_monitor/api/views/mixins/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "price_monitor/app_settings.py",
"chars": 5314,
"preview": "from django.conf import settings\nfrom django.utils.translation import ugettext_lazy\n\n\n# global AWS access settings\nPRICE"
},
{
"path": "price_monitor/forms.py",
"chars": 1988,
"preview": "\"\"\"Form definitions for frontend\"\"\"\nfrom . import app_settings as settings\nfrom .models.EmailNotification import EmailNo"
},
{
"path": "price_monitor/locale/de/LC_MESSAGES/django.po",
"chars": 6373,
"preview": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same "
},
{
"path": "price_monitor/management/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "price_monitor/management/commands/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "price_monitor/management/commands/price_monitor_batch_create_products.py",
"chars": 1066,
"preview": "\"\"\"Management command for batch reation of products\"\"\"\nfrom django.core.management.base import BaseCommand\n\nfrom price_m"
},
{
"path": "price_monitor/management/commands/price_monitor_clean_db.py",
"chars": 1606,
"preview": "\"\"\"Management command for removing invalid data from database\"\"\"\nfrom django.core.management.base import BaseCommand\n\nfr"
},
{
"path": "price_monitor/management/commands/price_monitor_recreate_product.py",
"chars": 771,
"preview": "\"\"\"Management command for recreating a product\"\"\"\nfrom django.core.management.base import BaseCommand\n\nfrom price_monito"
},
{
"path": "price_monitor/management/commands/price_monitor_search.py",
"chars": 860,
"preview": "\"\"\"Management command for searching Amazon\"\"\"\nfrom django.core.management.base import BaseCommand\n\nfrom price_monitor.pr"
},
{
"path": "price_monitor/management/commands/price_monitor_send_test_mail.py",
"chars": 1379,
"preview": "\"\"\"Management command for sending a pricemonitor specific test email\"\"\"\nfrom datetime import datetime\n\nfrom django.contr"
},
{
"path": "price_monitor/migrations/0001_initial.py",
"chars": 5734,
"preview": "# -*- coding: utf-8 -*-\nfrom __future__ import unicode_literals\n\nfrom django.db import models, migrations\nfrom django.co"
},
{
"path": "price_monitor/migrations/0002_add_min_max_fk_to_product.py",
"chars": 1019,
"preview": "# -*- coding: utf-8 -*-\nfrom __future__ import unicode_literals\n\nfrom django.db import models, migrations\n\n\nclass Migrat"
},
{
"path": "price_monitor/migrations/0003_datamigration_for_min_max_cur_fks.py",
"chars": 788,
"preview": "# -*- coding: utf-8 -*-\nfrom __future__ import unicode_literals\n\nfrom django.db import migrations\n\n\ndef set_prices(apps,"
},
{
"path": "price_monitor/migrations/0004_make_price_and_currency_nullable.py",
"chars": 653,
"preview": "# -*- coding: utf-8 -*-\nfrom __future__ import unicode_literals\n\nfrom django.db import models, migrations\n\n\nclass Migrat"
},
{
"path": "price_monitor/migrations/0005_product_artist.py",
"chars": 468,
"preview": "# -*- coding: utf-8 -*-\nfrom __future__ import unicode_literals\n\nfrom django.db import models, migrations\n\n\nclass Migrat"
},
{
"path": "price_monitor/migrations/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "price_monitor/models/EmailNotification.py",
"chars": 1101,
"preview": "\"\"\"Model for an email based notification\"\"\"\nfrom .mixins.PublicIDMixin import PublicIDMixin\n\nfrom django.conf import set"
},
{
"path": "price_monitor/models/Price.py",
"chars": 1223,
"preview": "\"\"\"Definition of a model for prices\"\"\"\nfrom django.db import models\nfrom django.utils.translation import ugettext as _, "
},
{
"path": "price_monitor/models/Product.py",
"chars": 6026,
"preview": "\"\"\"Model for an Amazon product\"\"\"\nfrom django.conf import settings\nfrom django.db import models\nfrom django.utils import"
},
{
"path": "price_monitor/models/Subscription.py",
"chars": 1803,
"preview": "\"\"\"The subscription model\"\"\"\nfrom .mixins.PublicIDMixin import PublicIDMixin\n\nfrom django.conf import settings\nfrom djan"
},
{
"path": "price_monitor/models/__init__.py",
"chars": 2299,
"preview": "\"\"\"Base module for models that are in module entities. Sets all signal handlers\"\"\"\nimport os\n\nfrom django.db.models.sign"
},
{
"path": "price_monitor/models/mixins/PublicIDMixin.py",
"chars": 1006,
"preview": "\"\"\"Mixin for having a public id.\"\"\"\nfrom django.db import models\nfrom django.utils.translation import ugettext as _\n\nfro"
},
{
"path": "price_monitor/models/mixins/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "price_monitor/product_advertising_api/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "price_monitor/product_advertising_api/api.py",
"chars": 7313,
"preview": "import bottlenose\nimport logging\nimport random\nimport time\n\nfrom bs4 import BeautifulSoup\n\nfrom dateutil import parser\n\n"
},
{
"path": "price_monitor/product_advertising_api/tasks.py",
"chars": 15113,
"preview": "# pylint: disable=unused-argument, arguments-differ\n\"\"\"Celery tasks for the Amazon Product Advertising API\"\"\"\nimport log"
},
{
"path": "price_monitor/static/price_monitor/angular/angular-django-rest-resource.js",
"chars": 18482,
"preview": "'use strict';\n\n//Portions of this file:\n//Copyright (c) 2010-2012 Google, Inc. http://angularjs.org\n//Those portions mod"
},
{
"path": "price_monitor/static/price_monitor/angular/angular-responsive-images.js",
"chars": 4490,
"preview": "/**\n * Angular responsive images\n * @version v0.0.0-dev-2013-06-19\n * @link https://github.com/c0bra/angular-res-img.git"
},
{
"path": "price_monitor/static/price_monitor/app/css/app.css",
"chars": 785,
"preview": ".content {\n margin-top: 55px;\n}\n\n.media:first-child {\n /* This is the default */\n margin-top: 15px;\n}\n\n.media.l"
},
{
"path": "price_monitor/static/price_monitor/app/css/xeditable.css",
"chars": 1377,
"preview": "/*!\nangular-xeditable - 0.1.8\nEdit-in-place for angular.js\nBuild date: 2014-01-10 \n*/\n\n.editable-wrap{display:inline-blo"
},
{
"path": "price_monitor/static/price_monitor/app/js/app.js",
"chars": 1412,
"preview": "'use strict';\n\nvar PriceMonitorApp = angular.module(\n 'PriceMonitorApp', \n [\n 'ngCookies',\n 'ngRoute"
},
{
"path": "price_monitor/static/price_monitor/app/js/controller/emailnotification-create-ctrl.js",
"chars": 404,
"preview": "PriceMonitorApp.controller('EmailNotificationCreateCtrl', function ($scope, $modalInstance, EmailNotification) {\n $sc"
},
{
"path": "price_monitor/static/price_monitor/app/js/controller/main-ctrl.js",
"chars": 195,
"preview": "PriceMonitorApp.controller('MainCtrl', function ($scope, $location) {\n $scope.URIS = window.URIS;\n $scope.isActive"
},
{
"path": "price_monitor/static/price_monitor/app/js/controller/product-delete-ctrl.js",
"chars": 313,
"preview": "PriceMonitorApp.controller('ProductDeleteCtrl', function ($scope, $modalInstance, product) {\n $scope.product = produc"
},
{
"path": "price_monitor/static/price_monitor/app/js/controller/product-detail-ctrl.js",
"chars": 1014,
"preview": "PriceMonitorApp.controller('ProductDetailCtrl', function ($scope, $routeParams, $location, $modal, Product) {\n $scope"
},
{
"path": "price_monitor/static/price_monitor/app/js/controller/product-list-ctrl.js",
"chars": 2967,
"preview": "PriceMonitorApp.controller('ProductListCtrl', function($scope, $modal, Product, EmailNotification) {\n $scope.products"
},
{
"path": "price_monitor/static/price_monitor/app/js/filters.js",
"chars": 266,
"preview": "//We already have a limitTo filter built-in to angular,\n//let's make a startFrom filter\nPriceMonitorApp.filter('startFro"
},
{
"path": "price_monitor/static/price_monitor/app/js/server-connector.js",
"chars": 1357,
"preview": "'use strict';\n\nvar PriceMonitorServerConnector = angular.module('PriceMonitorServerConnector', ['ngResource', 'djangoRES"
},
{
"path": "price_monitor/static/price_monitor/app/partials/emailnotification-create.html",
"chars": 879,
"preview": "<form novalidate id=\"emailnotification-form\" name=\"emailnotification_form\" class=\"form-inline\">\n <div class=\"modal-he"
},
{
"path": "price_monitor/static/price_monitor/app/partials/product-delete.html",
"chars": 342,
"preview": "<div class=\"modal-header\">\n <h3 class=\"modal-title\">Delete \"{{ product.title }}\"</h3>\n</div>\n<div class=\"modal-body\">"
},
{
"path": "price_monitor/static/price_monitor/app/partials/product-detail.html",
"chars": 4386,
"preview": "<div class=\"media\">\n <a class=\"pull-left\" href=\"{{ product.offer_url }}\">\n <img class=\"media-object\" ng-src=\"{"
},
{
"path": "price_monitor/static/price_monitor/app/partials/product-list.html",
"chars": 5447,
"preview": "<center ng-hide=\"products.length == 0\">\n <pagination total-items=\"productCount\" ng-model=\"currentPage\" max-size=\"maxP"
},
{
"path": "price_monitor/static/price_monitor/bootstrap/css/bootstrap-theme.css",
"chars": 14936,
"preview": "/*!\n * Bootstrap v3.1.1 (http://getbootstrap.com)\n * Copyright 2011-2014 Twitter, Inc.\n * Licensed under MIT (https://gi"
},
{
"path": "price_monitor/static/price_monitor/bootstrap/css/bootstrap.css",
"chars": 121220,
"preview": "/*!\n * Bootstrap v3.1.1 (http://getbootstrap.com)\n * Copyright 2011-2014 Twitter, Inc.\n * Licensed under MIT (https://gi"
},
{
"path": "price_monitor/static/price_monitor/css/base.css",
"chars": 82,
"preview": "#footer div {\n font-size: 12px;\n margin-top: 30px;\n text-align: center;\n}"
},
{
"path": "price_monitor/static/price_monitor/css/inline-form.css",
"chars": 266,
"preview": "#empty-line {\n display: none;\n}\n\nform.form-inline .glyphicon {\n cursor: pointer;\n}\n\nform.form-inline .row {\n pa"
},
{
"path": "price_monitor/tasks.py",
"chars": 1162,
"preview": "\"\"\"General tasks\"\"\"\nimport logging\n\nfrom celery.task import Task\n\nfrom price_monitor.models import (\n Price,\n Prod"
},
{
"path": "price_monitor/templates/price_monitor/angular_index_view.html",
"chars": 5925,
"preview": "{% load static %}<!DOCTYPE html>\n<html ng-app=\"PriceMonitorApp\">\n <head>\n <title>Amazon Price Monitor</title>\n"
},
{
"path": "price_monitor/urls.py",
"chars": 230,
"preview": "from django.conf.urls import include, url\n\nfrom price_monitor.views import AngularIndexView\n\nurlpatterns = [\n url(r'^"
},
{
"path": "price_monitor/utils.py",
"chars": 2750,
"preview": "\"\"\"Several util functions\"\"\"\nimport logging\n\nfrom django.core.mail import send_mail as django_send_mail\nfrom django.util"
},
{
"path": "price_monitor/views.py",
"chars": 2066,
"preview": "import json\nimport logging\n\nfrom django.contrib.auth.decorators import login_required\nfrom django.http import HttpRespon"
},
{
"path": "setup.cfg",
"chars": 21,
"preview": "[wheel]\nuniversal = 1"
},
{
"path": "setup.py",
"chars": 1337,
"preview": "#!/usr/bin/env python\n\"\"\"Setup file for the django-amazon-price-monitor package.\"\"\"\ntry:\n from setuptools import setu"
},
{
"path": "tests/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/product_advertising_api/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/product_advertising_api/data.py",
"chars": 45149,
"preview": "product_sample_lookup_fail = \"\"\"\n<?xml version=\"1.0\" ?>\n<itemlookupresponse xmlns=\"http://webservices.amazon.com/AWSECom"
},
{
"path": "tests/product_advertising_api/test_api.py",
"chars": 43161,
"preview": "import datetime\n\nfrom .data import (\n product_sample_3_products,\n product_sample_10_products,\n product_sample_i"
},
{
"path": "tests/settings.py",
"chars": 911,
"preview": "import os\n\n\nTEST_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'tests')\n\nDATABASES = {\n 'default': {"
},
{
"path": "tests/test_product.py",
"chars": 2851,
"preview": "from django.test import TestCase\n\nfrom price_monitor import app_settings\nfrom price_monitor.models import Product\n\n\nclas"
},
{
"path": "tests/test_utils.py",
"chars": 868,
"preview": "\"\"\"Tests for the utils module.\"\"\"\nfrom django.test import TestCase\n\nfrom price_monitor import utils\n\n\nclass UtilsTest(Te"
},
{
"path": "tox.ini",
"chars": 1642,
"preview": "[tox]\nenvlist =\n py34-django1.8,\n py34-django1.9,\n py34-django1.10,\n py34-django1.11,\n py35-django1.9,\n "
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the ponyriders/django-amazon-price-monitor GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 118 files (459.8 KB), approximately 136.9k tokens, and a symbol index with 157 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.