├── tests ├── __init__.py ├── product_advertising_api │ └── __init__.py ├── test_utils.py ├── settings.py └── test_product.py ├── price_monitor ├── api │ ├── __init__.py │ ├── views │ │ ├── __init__.py │ │ ├── mixins │ │ │ ├── __init__.py │ │ │ └── ProductFilteringMixin.py │ │ ├── ProductListView.py │ │ ├── SubscriptionListView.py │ │ ├── SubscriptionRetrieveView.py │ │ ├── PriceListView.py │ │ ├── EmailNotificationListView.py │ │ └── ProductCreateRetrieveUpdateDestroyAPIView.py │ ├── renderers │ │ ├── __init__.py │ │ └── PriceChartPNGRenderer.py │ ├── serializers │ │ ├── __init__.py │ │ ├── PriceSerializer.py │ │ ├── EmailNotificationSerializer.py │ │ ├── SubscriptionSerializer.py │ │ └── ProductSerializer.py │ └── urls.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── price_monitor_recreate_product.py │ │ ├── price_monitor_search.py │ │ ├── price_monitor_batch_create_products.py │ │ ├── price_monitor_send_test_mail.py │ │ └── price_monitor_clean_db.py ├── migrations │ ├── __init__.py │ ├── 0005_product_artist.py │ ├── 0004_make_price_and_currency_nullable.py │ ├── 0003_datamigration_for_min_max_cur_fks.py │ ├── 0002_add_min_max_fk_to_product.py │ └── 0001_initial.py ├── models │ ├── mixins │ │ ├── __init__.py │ │ └── PublicIDMixin.py │ ├── EmailNotification.py │ ├── Price.py │ ├── Subscription.py │ ├── __init__.py │ └── Product.py ├── product_advertising_api │ ├── __init__.py │ └── api.py ├── static │ └── price_monitor │ │ ├── css │ │ ├── base.css │ │ └── inline-form.css │ │ ├── bootstrap │ │ ├── fonts │ │ │ ├── glyphicons-halflings-regular.eot │ │ │ ├── glyphicons-halflings-regular.ttf │ │ │ └── glyphicons-halflings-regular.woff │ │ └── css │ │ │ ├── bootstrap-theme.min.css │ │ │ └── bootstrap-theme.css │ │ ├── app │ │ ├── js │ │ │ ├── controller │ │ │ │ ├── main-ctrl.js │ │ │ │ ├── product-delete-ctrl.js │ │ │ │ ├── emailnotification-create-ctrl.js │ │ │ │ ├── product-detail-ctrl.js │ │ │ │ └── product-list-ctrl.js │ │ │ ├── filters.js │ │ │ ├── server-connector.js │ │ │ └── app.js │ │ ├── partials │ │ │ ├── product-delete.html │ │ │ ├── emailnotification-create.html │ │ │ ├── product-detail.html │ │ │ └── product-list.html │ │ └── css │ │ │ ├── app.css │ │ │ └── xeditable.css │ │ └── angular │ │ ├── angular-cookies.min.js │ │ ├── angular-resource.min.js │ │ ├── angular-route.min.js │ │ └── angular-responsive-images.js ├── locale │ └── de │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── urls.py ├── __init__.py ├── tasks.py ├── forms.py ├── views.py ├── admin.py ├── utils.py ├── app_settings.py └── templates │ └── price_monitor │ └── angular_index_view.html ├── docker ├── web │ ├── project │ │ ├── glue_auth │ │ │ ├── models.py │ │ │ ├── __init__.py │ │ │ ├── templates │ │ │ │ ├── price_monitor │ │ │ │ │ └── angular_index_view.html │ │ │ │ └── glue_auth │ │ │ │ │ ├── base.html │ │ │ │ │ └── login.html │ │ │ ├── urls.py │ │ │ └── fixtures │ │ │ │ └── admin.json │ │ ├── requirements.pip │ │ ├── glue │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ ├── wsgi.py │ │ │ ├── celery.py │ │ │ └── settings.py │ │ └── manage.py │ ├── celery_run.sh │ ├── web_run.sh │ ├── django-amazon-price-monitor │ │ ├── price_monitor │ │ │ └── __init__.py │ │ └── setup.py │ └── Dockerfile ├── .gitignore ├── compose.env ├── base │ └── Dockerfile └── docker-compose.yml ├── setup.cfg ├── .coveragerc ├── hooks └── pre-commit ├── models.png ├── MANIFEST.in ├── docs └── price_monitor.product_advertising_api.tasks.png ├── .editorconfig ├── .gitignore ├── .landscape.yaml ├── Makefile ├── LICENSE ├── .travis.yml ├── CONTRIBUTING.rst ├── setup.py ├── tox.ini └── HISTORY.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /price_monitor/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker/web/project/glue_auth/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /price_monitor/api/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /price_monitor/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /price_monitor/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 -------------------------------------------------------------------------------- /docker/web/project/glue_auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /price_monitor/api/renderers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /price_monitor/api/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /price_monitor/api/views/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /price_monitor/models/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/product_advertising_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /price_monitor/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /price_monitor/product_advertising_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = price_monitor/migrations/* -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | flake8 price_monitor --ignore=E501,E128 --exclude=migrations -------------------------------------------------------------------------------- /models.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponyriders/django-amazon-price-monitor/HEAD/models.png -------------------------------------------------------------------------------- /docker/web/celery_run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # wait for redis 3 | sleep 5 4 | celery --beat -A glue worker -------------------------------------------------------------------------------- /docker/.gitignore: -------------------------------------------------------------------------------- 1 | docker-compose.override.yml 2 | logs 3 | media 4 | postgres 5 | web/project/celerybeat-schedule.db -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/css/base.css: -------------------------------------------------------------------------------- 1 | #footer div { 2 | font-size: 12px; 3 | margin-top: 30px; 4 | text-align: center; 5 | } -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include HISTORY.rst 3 | recursive-include price_monitor *.html *.png *.gif *js *.css *jpg *jpeg *svg *py *eot *ttf *woff 4 | -------------------------------------------------------------------------------- /docker/web/project/requirements.pip: -------------------------------------------------------------------------------- 1 | Django<2 2 | dj-database-url 3 | psycopg2>=2.5.4 4 | celery>=4,<5 5 | django-redis-cache>=1.5.4 6 | hiredis<0.3 7 | Pillow<6 -------------------------------------------------------------------------------- /price_monitor/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponyriders/django-amazon-price-monitor/HEAD/price_monitor/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /docs/price_monitor.product_advertising_api.tasks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponyriders/django-amazon-price-monitor/HEAD/docs/price_monitor.product_advertising_api.tasks.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.rst] 2 | indent_style = tab 3 | indent_size = 4 4 | 5 | [*.json] 6 | indent_style = space 7 | indent_size = 4 8 | 9 | [Makefile] 10 | indent_style = tab 11 | indent_size = 4 -------------------------------------------------------------------------------- /docker/web/web_run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # wait for postgres 3 | sleep 5 4 | cd /srv/project/ 5 | python3 manage.py migrate 6 | python3 manage.py loaddata admin 7 | python3 manage.py runserver 0.0.0.0:8000 -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/bootstrap/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponyriders/django-amazon-price-monitor/HEAD/price_monitor/static/price_monitor/bootstrap/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/bootstrap/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponyriders/django-amazon-price-monitor/HEAD/price_monitor/static/price_monitor/bootstrap/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/bootstrap/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponyriders/django-amazon-price-monitor/HEAD/price_monitor/static/price_monitor/bootstrap/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /docker/web/project/glue/__init__.py: -------------------------------------------------------------------------------- 1 | """Glue project init""" 2 | from __future__ import absolute_import 3 | 4 | # This will make sure the app is always imported when 5 | # Django starts so that shared_task will use this app. 6 | from .celery import app as celery_app # noqa 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | .coverage 5 | .cache 6 | .env 7 | .idea 8 | .project 9 | .pydevproject 10 | .settings 11 | .tox 12 | .vscode 13 | build 14 | dist 15 | django_amazon_price_monitor.egg-info 16 | price_monitor/management/commands/price_monitor_dev.py -------------------------------------------------------------------------------- /docker/web/project/glue_auth/templates/price_monitor/angular_index_view.html: -------------------------------------------------------------------------------- 1 | {% extends "price_monitor/angular_index_view.html" %} 2 | 3 | 4 | {% block footer %} 5 |
6 |
Template-Block: footer
7 |
8 | {% endblock %} -------------------------------------------------------------------------------- /price_monitor/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | 3 | from price_monitor.views import AngularIndexView 4 | 5 | urlpatterns = [ 6 | url(r'^$', AngularIndexView.as_view(), name='angular_view'), 7 | url(r'^api/', include('price_monitor.api.urls')), 8 | ] 9 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/js/controller/main-ctrl.js: -------------------------------------------------------------------------------- 1 | PriceMonitorApp.controller('MainCtrl', function ($scope, $location) { 2 | $scope.URIS = window.URIS; 3 | $scope.isActive = function (route) { 4 | return route === $location.path(); 5 | } 6 | }); 7 | -------------------------------------------------------------------------------- /.landscape.yaml: -------------------------------------------------------------------------------- 1 | doc-warnings: yes 2 | max-line-length: 160 3 | uses: 4 | - django 5 | - celery 6 | ignore-paths: 7 | - docs 8 | - hooks 9 | - price_monitor/migrations 10 | pylint: 11 | disable: 12 | - invalid-encoded-data 13 | - model-missing-unicode 14 | python-targets: 15 | - 3 -------------------------------------------------------------------------------- /docker/compose.env: -------------------------------------------------------------------------------- 1 | PYTHONUNBUFFERED=1 2 | POSTGRES_USER=pm_user 3 | POSTGRES_DB=pm_db 4 | POSTGRES_PASSWORD=6i2vmzq5C6BuSf5k33A6tmMSHwKKv0Pu 5 | DEBUG='True' 6 | SECRET_KEY=Vceev7yWMtEQzHaTZX52 7 | EMAIL_BACKEND=django.core.mail.backends.filebased.EmailBackend 8 | C_FORCE_ROOT='True' 9 | CELERY_BROKER_URL=redis://redis/1 -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/js/filters.js: -------------------------------------------------------------------------------- 1 | //We already have a limitTo filter built-in to angular, 2 | //let's make a startFrom filter 3 | PriceMonitorApp.filter('startFrom', function() { 4 | return function(input, start) { 5 | start = parseInt(start); //parse to int 6 | return input.slice(start); 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /docker/web/project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Main Django entry point.""" 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "glue.settings") 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /docker/web/project/glue/urls.py: -------------------------------------------------------------------------------- 1 | """URL definitions for the glue project.""" 2 | from django.conf.urls import include, url 3 | from django.contrib import admin 4 | 5 | 6 | admin.autodiscover() 7 | 8 | urlpatterns = [ 9 | url(r'^admin/', admin.site.urls), 10 | url(r'^', include('glue_auth.urls')), 11 | url(r'^', include('price_monitor.urls')), 12 | ] 13 | -------------------------------------------------------------------------------- /docker/web/project/glue_auth/urls.py: -------------------------------------------------------------------------------- 1 | """URL definitions for the glue_auth module.""" 2 | from django.conf.urls import url 3 | from django.contrib.auth.views import login, logout_then_login 4 | 5 | 6 | app_name = 'glue_auth' 7 | urlpatterns = [ 8 | url(r'^login/$', login, {'template_name': 'glue_auth/login.html'}, name='login'), 9 | url(r'^logout/$', logout_then_login, name='logout'), 10 | ] 11 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/css/inline-form.css: -------------------------------------------------------------------------------- 1 | #empty-line { 2 | display: none; 3 | } 4 | 5 | form.form-inline .glyphicon { 6 | cursor: pointer; 7 | } 8 | 9 | form.form-inline .row { 10 | padding: 5px 0; 11 | } 12 | 13 | form.form-inline input, 14 | form.form-inline select { 15 | width: 100%; 16 | } 17 | 18 | form.form-inline #form-add-button { 19 | margin-left: 5px; 20 | } -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/js/controller/product-delete-ctrl.js: -------------------------------------------------------------------------------- 1 | PriceMonitorApp.controller('ProductDeleteCtrl', function ($scope, $modalInstance, product) { 2 | $scope.product = product; 3 | $scope.ok = function () { 4 | $scope.product.$delete(); 5 | $modalInstance.close(); 6 | }; 7 | $scope.cancel = function () { 8 | $modalInstance.dismiss('cancel'); 9 | }; 10 | }); 11 | -------------------------------------------------------------------------------- /docker/web/django-amazon-price-monitor/price_monitor/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy init module for the price_monitor package. It will be overwritten when docker mounts the real package.""" 2 | 3 | 4 | def get_version(): 5 | """ 6 | Returns DEV as version. Just a placeholder while building the web docker image, will be overwritten by mounted docker volume. 7 | 8 | :return: the version identifier 9 | """ 10 | return 'DEV' 11 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/partials/product-delete.html: -------------------------------------------------------------------------------- 1 | 4 | 7 | 11 | -------------------------------------------------------------------------------- /docker/web/project/glue/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for glue project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | # FIXME this is not production ready 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "glue.settings") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /price_monitor/api/serializers/PriceSerializer.py: -------------------------------------------------------------------------------- 1 | """Serializer for Price model""" 2 | from ...models import Price 3 | 4 | from rest_framework import serializers 5 | 6 | 7 | class PriceSerializer(serializers.ModelSerializer): 8 | 9 | """Serializes prices by showing currency, value and date seen""" 10 | 11 | class Meta(object): 12 | 13 | """Some model meta""" 14 | 15 | model = Price 16 | fields = ( 17 | 'value', 18 | 'currency', 19 | 'date_seen', 20 | ) 21 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/js/controller/emailnotification-create-ctrl.js: -------------------------------------------------------------------------------- 1 | PriceMonitorApp.controller('EmailNotificationCreateCtrl', function ($scope, $modalInstance, EmailNotification) { 2 | $scope.email_notification = {}; 3 | 4 | $scope.ok = function (email_notification) { 5 | EmailNotification.save(email_notification, function() { 6 | $modalInstance.close(); 7 | }); 8 | }; 9 | $scope.cancel = function () { 10 | $modalInstance.dismiss('cancel'); 11 | }; 12 | }); 13 | -------------------------------------------------------------------------------- /price_monitor/migrations/0005_product_artist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('price_monitor', '0004_make_price_and_currency_nullable'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='product', 16 | name='artist', 17 | field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Artist'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | @echo "docker-build-base: - builds the base docker image (not necessary normally as image is on docker hub)" 3 | @echo "docker-build-web: - builds the web docker image" 4 | 5 | docker-build-base: 6 | docker build -t pricemonitor/base docker/base/ 7 | 8 | docker-build-web: 9 | cp setup.py docker/web/django-amazon-price-monitor/setup.py 10 | sed -i 's/readme = .*/readme = ""/g' docker/web/django-amazon-price-monitor/setup.py 11 | sed -i 's/history = .*/history = ""/g' docker/web/django-amazon-price-monitor/setup.py 12 | docker build -t pricemonitor/web docker/web/ 13 | 14 | -------------------------------------------------------------------------------- /docker/web/project/glue/celery.py: -------------------------------------------------------------------------------- 1 | """Celery setup for the glue project.""" 2 | from __future__ import absolute_import 3 | 4 | import os 5 | 6 | from celery import Celery 7 | 8 | from django.conf import settings 9 | 10 | 11 | # set the default Django settings module for the 'celery' program. 12 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'glue.settings') 13 | 14 | app = Celery('glue') 15 | 16 | # Using a string here means the worker will not have to 17 | # pickle the object when using Windows. 18 | app.config_from_object('django.conf:settings', namespace='CELERY') 19 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 20 | -------------------------------------------------------------------------------- /price_monitor/api/serializers/EmailNotificationSerializer.py: -------------------------------------------------------------------------------- 1 | """Serializer for EmailNotification model""" 2 | from ...models import EmailNotification 3 | 4 | from rest_framework import serializers 5 | 6 | 7 | class EmailNotificationSerializer(serializers.ModelSerializer): 8 | 9 | """Serializes EmailNotification objects. Just renders public_id as id and the email address""" 10 | 11 | owner = serializers.HiddenField( 12 | default=serializers.CurrentUserDefault() 13 | ) 14 | 15 | class Meta(object): 16 | 17 | """Some model meta""" 18 | 19 | model = EmailNotification 20 | fields = ('owner', 'email',) 21 | -------------------------------------------------------------------------------- /docker/web/project/glue_auth/fixtures/admin.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "auth.user", 4 | "pk": 1, 5 | "fields": { 6 | "password": "pbkdf2_sha256$24000$A7AExuKNKQp3$I4oqUrkVc6LVIZSv2f8DIbjWTSoD1entAJDHjOMV5OI=", 7 | "last_login": null, 8 | "is_superuser": true, 9 | "username": "admin", 10 | "first_name": "", 11 | "last_name": "", 12 | "email": "admin@localhost", 13 | "is_staff": true, 14 | "is_active": true, 15 | "date_joined": "2016-03-19T13:16:19.168Z", 16 | "groups": [], 17 | "user_permissions": [] 18 | } 19 | } 20 | ] -------------------------------------------------------------------------------- /docker/web/project/glue_auth/templates/glue_auth/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | {% block pagetitle %}Pricemonitor Site{% endblock %} 7 | 8 | 9 | {% block css_links %}{% endblock %} 10 | {% block css_inline %}{% endblock %} 11 | {% block js_links %}{% endblock %} 12 | {% block js_inline %}{% endblock %} 13 | 14 | 15 | {% block content %}Intentionally blank page.{% endblock %} 16 | 17 | -------------------------------------------------------------------------------- /docker/base/Dockerfile: -------------------------------------------------------------------------------- 1 | # basic setup 2 | FROM philcryer/min-jessie 3 | MAINTAINER Alexander Herrmann 4 | 5 | # install basic packages: lxml dependencies, python3 and git 6 | # see recommendation https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#apt-get 7 | RUN apt-get update && apt-get install -y \ 8 | git \ 9 | libffi-dev \ 10 | libjpeg-dev \ 11 | libpq-dev \ 12 | libxml2-dev \ 13 | libxslt1-dev \ 14 | postgresql-client-9.4 \ 15 | python3-cairo \ 16 | python3-minimal \ 17 | python3-pip \ 18 | && rm -rf /tmp/* /var/tmp/* \ 19 | && apt-get clean \ 20 | && rm -rf /var/lib/apt/lists/* 21 | 22 | # install lxml and psycopg2 - they take the most amount of time compiling 23 | RUN pip3 install lxml psycopg2 setuptools -------------------------------------------------------------------------------- /price_monitor/api/views/ProductListView.py: -------------------------------------------------------------------------------- 1 | """View for listing subscriptions""" 2 | from rest_framework import generics, permissions 3 | 4 | from .mixins.ProductFilteringMixin import ProductFilteringMixin 5 | from ..serializers.ProductSerializer import ProductSerializer 6 | from ...models.Product import Product 7 | 8 | 9 | class ProductListView(ProductFilteringMixin, generics.ListAPIView): 10 | 11 | """Returns list of Products and provides endpoint to create Products, if user is authenticated.""" 12 | 13 | model = Product 14 | serializer_class = ProductSerializer 15 | allow_empty = True 16 | queryset = Product.objects.all() 17 | permission_classes = [ 18 | # only return the list if user is authenticated 19 | permissions.IsAuthenticated 20 | ] 21 | -------------------------------------------------------------------------------- /price_monitor/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | django-amazon-price-monitor monitors prices of Amazon products. 3 | """ 4 | __version_info__ = { 5 | 'major': 0, 6 | 'minor': 7, 7 | 'micro': 0, 8 | 'releaselevel': 'final', 9 | 'serial': 0, 10 | } 11 | 12 | 13 | def get_version(short=False): 14 | assert __version_info__['releaselevel'] in ('alpha', 'beta', 'final') 15 | version = ["{major:d}.{minor:d}".format(**__version_info__), ] 16 | if __version_info__['micro']: 17 | version.append(".{micro:d}".format(**__version_info__)) 18 | if __version_info__['releaselevel'] != 'final' and not short: 19 | version.append('{0!s}{1:d}'.format(__version_info__['releaselevel'][0], __version_info__['serial'])) 20 | return ''.join(version) 21 | 22 | __version__ = get_version() 23 | -------------------------------------------------------------------------------- /price_monitor/migrations/0004_make_price_and_currency_nullable.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('price_monitor', '0003_datamigration_for_min_max_cur_fks'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='price', 16 | name='currency', 17 | field=models.CharField(null=True, verbose_name='Currency', blank=True, max_length=3), 18 | ), 19 | migrations.AlterField( 20 | model_name='price', 21 | name='value', 22 | field=models.FloatField(null=True, verbose_name='Price', blank=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /docker/web/project/glue_auth/templates/glue_auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'glue_auth/base.html' %} 2 | 3 | 4 | {% block content %} 5 | {% if form.errors %} 6 |

Your username and password didn't match. Please try again.

7 | {% endif %} 8 |
9 | {% csrf_token %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
{{ form.username.label_tag }}{{ form.username }}
{{ form.password.label_tag }}{{ form.password }}
20 | 21 | 22 | 23 |
24 | {% endblock %} -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/angular/angular-cookies.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | AngularJS v1.3.9 3 | (c) 2010-2014 Google, Inc. http://angularjs.org 4 | License: MIT 5 | */ 6 | (function(p,f,n){'use strict';f.module("ngCookies",["ng"]).factory("$cookies",["$rootScope","$browser",function(e,b){var c={},g={},h,k=!1,l=f.copy,m=f.isUndefined;b.addPollFn(function(){var a=b.cookies();h!=a&&(h=a,l(a,g),l(a,c),k&&e.$apply())})();k=!0;e.$watch(function(){var a,d,e;for(a in g)m(c[a])&&b.cookies(a,n);for(a in c)d=c[a],f.isString(d)||(d=""+d,c[a]=d),d!==g[a]&&(b.cookies(a,d),e=!0);if(e)for(a in d=b.cookies(),c)c[a]!==d[a]&&(m(d[a])?delete c[a]:c[a]=d[a])});return c}]).factory("$cookieStore", 7 | ["$cookies",function(e){return{get:function(b){return(b=e[b])?f.fromJson(b):b},put:function(b,c){e[b]=f.toJson(c)},remove:function(b){delete e[b]}}}])})(window,window.angular); 8 | //# sourceMappingURL=angular-cookies.min.js.map 9 | -------------------------------------------------------------------------------- /docker/web/Dockerfile: -------------------------------------------------------------------------------- 1 | # basic setup, use base image of treasury project 2 | FROM pricemonitor/base:latest 3 | MAINTAINER Alexander Herrmann 4 | 5 | # django setup, create default folder and volumes 6 | WORKDIR /srv/ 7 | RUN mkdir static 8 | VOLUME ["/srv/media", "/srv/logs", "/srv/pricemonitor", "/srv/project"] 9 | 10 | # copy the django project files 11 | COPY project /srv/project 12 | COPY web_run.sh /srv/web_run.sh 13 | COPY celery_run.sh /srv/celery_run.sh 14 | 15 | # install python dependencies for the django project 16 | RUN pip3 install -r /srv/project/requirements.pip 17 | 18 | # copy the treasury package and install - will be mounted later through data container (and thus overwritten with the host files) 19 | ADD django-amazon-price-monitor /srv/pricemonitor 20 | RUN pip3 install -e /srv/pricemonitor 21 | 22 | # ports 23 | EXPOSE 8000 24 | 25 | # entrypoint 26 | WORKDIR /srv/project -------------------------------------------------------------------------------- /price_monitor/management/commands/price_monitor_recreate_product.py: -------------------------------------------------------------------------------- 1 | """Management command for recreating a product""" 2 | from django.core.management.base import BaseCommand 3 | 4 | from price_monitor.models import Product 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Recreates a product with the given asin. If product already exists, it is deleted.' 9 | 10 | def add_arguments(self, parser): 11 | """ 12 | Adds the positional argument for ASIN 13 | 14 | :param parser: the argument parser 15 | """ 16 | parser.add_argument('asin', nargs=1, type=str) 17 | 18 | def handle(self, *args, **options): 19 | """Recreates the product with given ASIN""" 20 | asin = options['asin'][0] 21 | product, created = Product.objects.get_or_create(asin=asin) 22 | if not created: 23 | product.delete() 24 | Product.objects.create(asin=asin) 25 | -------------------------------------------------------------------------------- /price_monitor/api/views/SubscriptionListView.py: -------------------------------------------------------------------------------- 1 | """View for listing subscriptions""" 2 | from ..serializers.SubscriptionSerializer import SubscriptionSerializer 3 | from ...models.Subscription import Subscription 4 | 5 | from rest_framework import generics, permissions 6 | 7 | 8 | class SubscriptionListView(generics.ListAPIView): 9 | 10 | """Returns list of subscriptions, if user is authenticated""" 11 | 12 | model = Subscription 13 | serializer_class = SubscriptionSerializer 14 | allow_empty = True 15 | 16 | permission_classes = [ 17 | # only return the list if user is authenticated 18 | permissions.IsAuthenticated 19 | ] 20 | 21 | def get_queryset(self): 22 | """ 23 | Filters queryset by the authenticated user 24 | 25 | :returns: filtered Subscription objects 26 | :rtype: QuerySet 27 | """ 28 | return self.model.objects.filter(owner=self.request.user) 29 | -------------------------------------------------------------------------------- /price_monitor/migrations/0003_datamigration_for_min_max_cur_fks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | 6 | 7 | def set_prices(apps, schema_editor): 8 | """ 9 | Sets min, max and current price 10 | """ 11 | for product in apps.get_model('price_monitor', 'Product').objects.all(): 12 | if product.price_set.count() > 0: 13 | product.current_price = product.price_set.latest('date_seen') 14 | product.highest_price = product.price_set.latest('value') 15 | product.lowest_price = product.price_set.earliest('value') 16 | product.save() 17 | 18 | 19 | class Migration(migrations.Migration): 20 | 21 | dependencies = [ 22 | ('price_monitor', '0002_add_min_max_fk_to_product'), 23 | ] 24 | 25 | operations = [ 26 | migrations.RunPython(set_prices, reverse_code=migrations.RunPython.noop), 27 | ] 28 | -------------------------------------------------------------------------------- /price_monitor/api/views/SubscriptionRetrieveView.py: -------------------------------------------------------------------------------- 1 | """View for retrieving a subscription""" 2 | from ..serializers.SubscriptionSerializer import SubscriptionSerializer 3 | from ...models.Subscription import Subscription 4 | 5 | from rest_framework import generics, permissions 6 | 7 | 8 | class SubscriptionRetrieveView(generics.RetrieveAPIView): 9 | 10 | """Returns instance of Subscription, if user is authenticated""" 11 | 12 | model = Subscription 13 | serializer_class = SubscriptionSerializer 14 | lookup_field = 'public_id' 15 | permission_classes = [ 16 | # only return the list if user is authenticated 17 | permissions.IsAuthenticated 18 | ] 19 | 20 | def get_queryset(self): 21 | """ 22 | 23 | Filters queryset by the authenticated user 24 | :returns: filtered Subscription objects 25 | :rtype: QuerySet 26 | """ 27 | return self.model.objects.filter(owner=self.request.user) 28 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | # database container 4 | db: 5 | image: postgres:9 6 | env_file: compose.env 7 | volumes: 8 | - ./postgres:/var/lib/postgresql/data 9 | 10 | # redis for celery 11 | redis: 12 | image: redis:3 13 | 14 | # web container with Django project 15 | web: 16 | build: ./web 17 | image: pricemonitor/web 18 | depends_on: 19 | - db 20 | ports: 21 | - "8000:8000" 22 | env_file: compose.env 23 | command: /srv/web_run.sh 24 | volumes: 25 | - ./logs:/srv/logs 26 | - ./media:/srv/media 27 | - ./web/project:/srv/project 28 | - ../:/srv/pricemonitor 29 | 30 | # celery container 31 | celery: 32 | build: ./web 33 | image: pricemonitor/web 34 | depends_on: 35 | - redis 36 | env_file: compose.env 37 | command: /srv/celery_run.sh 38 | volumes: 39 | - ./web/project:/srv/project 40 | - ../:/srv/pricemonitor 41 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for the utils module.""" 2 | from django.test import TestCase 3 | 4 | from price_monitor import utils 5 | 6 | 7 | class UtilsTest(TestCase): 8 | 9 | """Tests for the utils module.""" 10 | 11 | def test_get_offer_url(self): 12 | """Test the offer url function""" 13 | self.assertEqual('http://www.amazon.de/dp/X1234567890/?tag=sample-assoc-tag', utils.get_offer_url('X1234567890')) 14 | 15 | def test_chunk_list(self): 16 | """Tests the chunk_list function""" 17 | self.assertEqual( 18 | [[10, 11, 12, 13], [14, 15, 16, 17], [18, 19]], 19 | list(utils.chunk_list(list(range(10, 20)), 4)) 20 | ) 21 | self.assertEqual( 22 | [[1]], 23 | list(utils.chunk_list([1], 7)) 24 | ) 25 | self.assertEqual( 26 | [['L', 'o', 'r'], ['e', 'm', ' '], ['I', 'p', 's'], ['u', 'm']], 27 | list(utils.chunk_list(list('Lorem Ipsum'), 3)) 28 | ) 29 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/partials/emailnotification-create.html: -------------------------------------------------------------------------------- 1 |
2 | 5 | 14 | 18 |
19 | -------------------------------------------------------------------------------- /price_monitor/management/commands/price_monitor_search.py: -------------------------------------------------------------------------------- 1 | """Management command for searching Amazon""" 2 | from django.core.management.base import BaseCommand 3 | 4 | from price_monitor.product_advertising_api.api import ProductAdvertisingAPI 5 | 6 | from pprint import pprint 7 | 8 | 9 | class Command(BaseCommand): 10 | 11 | """Command for searching ASINs and displaying their return value""" 12 | 13 | help = 'Searches for products at Amazon (not within the database!) with the given ASINs and prints out their details.' 14 | 15 | def add_arguments(self, parser): 16 | """ 17 | Adds the positional argument for ASINs. 18 | 19 | :param parser: the argument parser 20 | """ 21 | parser.add_argument('asins', nargs='+', type=str) 22 | 23 | def handle(self, *args, **options): 24 | """Searches for a product with the given ASIN.""" 25 | asins = options['asins'] 26 | api = ProductAdvertisingAPI() 27 | pprint(api.item_lookup(asins), indent=4) 28 | -------------------------------------------------------------------------------- /price_monitor/api/serializers/SubscriptionSerializer.py: -------------------------------------------------------------------------------- 1 | """Serializer for Subscription model""" 2 | from .EmailNotificationSerializer import EmailNotificationSerializer 3 | from ...models import Subscription 4 | 5 | from rest_framework import serializers 6 | 7 | 8 | class SubscriptionSerializer(serializers.ModelSerializer): 9 | 10 | """Serializes subscription with product inline. Also renders id frm public_id""" 11 | 12 | # this field needs to be writable to get it's value into update function of ProductSerializer 13 | id = serializers.CharField(source='public_id', required=False) 14 | email_notification = EmailNotificationSerializer() 15 | 16 | class Meta(object): 17 | 18 | """Some model meta""" 19 | 20 | model = Subscription 21 | fields = ( 22 | 'id', 23 | 'price_limit', 24 | 'date_last_notification', 25 | 'email_notification', 26 | ) 27 | 28 | read_only_fields = ( 29 | 'date_last_notification', 30 | ) 31 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/css/app.css: -------------------------------------------------------------------------------- 1 | .content { 2 | margin-top: 55px; 3 | } 4 | 5 | .media:first-child { 6 | /* This is the default */ 7 | margin-top: 15px; 8 | } 9 | 10 | .media.list a, .media.list a:visited, .media.list a:hover, .media.list a:active { 11 | text-decoration: none; 12 | color: #000; 13 | } 14 | 15 | .media.list a.pull-left { 16 | height: 75px; 17 | width: 75px; 18 | } 19 | 20 | .media.list .media-body { 21 | margin-left: 85px; 22 | } 23 | 24 | .media.list .media-body img.sparkline { 25 | height: 40px; 26 | width: 400px; 27 | } 28 | 29 | #product-form .row { 30 | margin-top: 5px; 31 | } 32 | 33 | #product-form input, 34 | #emailnotification-form .form-group, 35 | #emailnotification-form .form-group input[type="email"] { 36 | width: 100%; 37 | } 38 | 39 | #product-form select { 40 | display: inline-block; 41 | width: 84%; 42 | } 43 | 44 | #product-form span.glyphicon { 45 | cursor: pointer; 46 | } 47 | 48 | .responsive-chart { 49 | margin-top: 15px; 50 | } 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a 2 | copy of this software and associated documentation files (the 3 | "Software"), to deal in the Software without restriction, including 4 | without limitation the rights to use, copy, modify, merge, publish, dis- 5 | tribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the fol- 7 | lowing conditions: 8 | 9 | The above copyright notice and this permission notice shall be included 10 | in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 13 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- 14 | ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 15 | SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 16 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 18 | IN THE SOFTWARE. -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | TEST_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'tests') 5 | 6 | DATABASES = { 7 | 'default': { 8 | 'ENGINE': 'django.db.backends.sqlite3', 9 | 'NAME': ':memory:', 10 | } 11 | } 12 | 13 | MIDDLEWARE_CLASSES = [ 14 | 'django.contrib.sessions.middleware.SessionMiddleware', 15 | 'django.middleware.common.CommonMiddleware', 16 | 'django.middleware.csrf.CsrfViewMiddleware', 17 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 18 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 19 | ] 20 | 21 | INSTALLED_APPS = [ 22 | 'django.contrib.auth', 23 | 'django.contrib.contenttypes', 24 | 'django.contrib.sessions', 25 | 'price_monitor', 26 | ] 27 | 28 | STATIC_URL = '/static/' 29 | 30 | STATIC_ROOT = os.path.join(TEST_DIR, 'static') 31 | 32 | SECRET_KEY = os.environ['SECRET_KEY'] 33 | 34 | ROOT_URLCONF = 'price_monitor.urls' 35 | 36 | PRICE_MONITOR_AMAZON_PRODUCT_API_REGION = 'DE' 37 | PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG = 'sample-assoc-tag' 38 | -------------------------------------------------------------------------------- /price_monitor/api/views/PriceListView.py: -------------------------------------------------------------------------------- 1 | """View for listing prices""" 2 | from ..renderers.PriceChartPNGRenderer import PriceChartPNGRenderer 3 | from ..serializers.PriceSerializer import PriceSerializer 4 | from ...models.Price import Price 5 | 6 | from datetime import timedelta 7 | 8 | from django.utils import timezone 9 | 10 | from rest_framework.generics import ListAPIView 11 | 12 | 13 | class PriceListView(ListAPIView): 14 | model = Price 15 | serializer_class = PriceSerializer 16 | renderer_classes = ListAPIView.renderer_classes + [PriceChartPNGRenderer] 17 | 18 | def get_queryset(self): 19 | """ 20 | Returns the elements matching the product's ASIN within the last 7 days. 21 | 22 | :return: QuerySet 23 | """ 24 | # 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 25 | return self.model.objects.filter( 26 | product__asin=self.kwargs.get('asin'), 27 | date_seen__gte=timezone.now() - timedelta(days=7), 28 | ).order_by('-date_seen') 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | matrix: 4 | include: 5 | - python: 3.4 6 | env: 7 | - TOXENV=py34-django1.8 8 | - python: 3.4 9 | env: 10 | - TOXENV=py34-django1.9 11 | - python: 3.4 12 | env: 13 | - TOXENV=py34-django1.10 14 | - python: 3.4 15 | env: 16 | - TOXENV=py34-django1.11 17 | - python: 3.5 18 | env: 19 | - TOXENV=py35-django1.9 20 | - python: 3.5 21 | env: 22 | - TOXENV=py35-django1.10 23 | - python: 3.5 24 | env: 25 | - TOXENV=py35-django1.11 26 | - python: 3.6 27 | env: 28 | - TOXENV=py36-django1.11 29 | install: 30 | - pip install codecov tox 31 | script: 32 | - tox 33 | after_success: 34 | - codecov 35 | notifications: 36 | email: false 37 | deploy: 38 | provider: pypi 39 | user: ponyriders 40 | password: 41 | secure: n04DQkYdiwg+XLVfJd/O3Jil7kUV1GeLK/gqTgAjOlpXmChhI3+2Xzg8hKoWYmtxXLqZu0zpvepHVi/y5Xz2R1va+eNjnQ9XKzZBt6t40+YaMKpUTZsP0fGocJr0imxuqmOOV8YJ7cZ3r4eX+4aUMMq2tE2j6b37MczTfBw1YmM= 42 | distributions: sdist bdist_wheel 43 | on: 44 | tags: true 45 | repo: ponyriders/django-amazon-price-monitor 46 | condition: "$TOXENV = py35-django1.11" 47 | -------------------------------------------------------------------------------- /price_monitor/api/views/mixins/ProductFilteringMixin.py: -------------------------------------------------------------------------------- 1 | """Mixin for product filtering""" 2 | from django.db.models.query import Prefetch 3 | 4 | from ....models.Subscription import Subscription 5 | 6 | 7 | class ProductFilteringMixin(object): 8 | 9 | """Mixin for filtering products of the current user and have the lowest, highest and current price included.""" 10 | 11 | def filter_queryset(self, queryset): 12 | """ 13 | Filters queryset by the authenticated user 14 | 15 | :returns: filtered Product objects 16 | :rtype: QuerySet 17 | """ 18 | queryset = super(ProductFilteringMixin, self).filter_queryset(queryset) 19 | return queryset\ 20 | .select_related('highest_price', 'lowest_price', 'current_price')\ 21 | .prefetch_related( 22 | Prefetch( 23 | 'subscription_set', 24 | queryset=Subscription.objects.filter( 25 | owner=self.request.user 26 | ).select_related('email_notification').distinct() 27 | ) 28 | ).filter(subscription__owner=self.request.user).distinct() 29 | -------------------------------------------------------------------------------- /price_monitor/migrations/0002_add_min_max_fk_to_product.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('price_monitor', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='product', 16 | name='highest_price', 17 | field=models.ForeignKey(related_name='product_highest', to='price_monitor.Price', blank=True, verbose_name='Highest price ever', null=True), 18 | ), 19 | migrations.AddField( 20 | model_name='product', 21 | name='lowest_price', 22 | field=models.ForeignKey(related_name='product_lowest', to='price_monitor.Price', blank=True, verbose_name='Lowest price ever', null=True), 23 | ), 24 | migrations.AddField( 25 | model_name='product', 26 | name='current_price', 27 | field=models.ForeignKey(to='price_monitor.Price', null=True, blank=True, verbose_name='Current price', related_name='product_current'), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | If you like to lend us a hand, feel free to contribute code to the project. Pick an issue or add what you miss. 5 | Please remember that we are only humans and offer this in our spare time. 6 | 7 | Fork the repo, then clone it: 8 | 9 | :: 10 | 11 | git clone git@github.com:your-username/django-amazon-price-monitor.git 12 | 13 | Ensure the tests run: 14 | 15 | tox 16 | 17 | Make your change. Add tests for your change. Make the tests pass: 18 | 19 | tox 20 | 21 | Push to your fork and `submit a pull request`_. 22 | 23 | At this point you're waiting on us. We like to at least comment on pull requests 24 | and we may suggest some changes or improvements or alternatives. 25 | 26 | Some things that will increase the chance that your pull request is accepted: 27 | 28 | * Write tests. 29 | * Follow the PEP8 style guide. 30 | * Write a `good commit message`_. 31 | 32 | .. _code of conduct: https://thoughtbot.com/open-source-code-of-conduct 33 | .. _submit a pull request: https://github.com/ponyriders/django-amazon-price-monitor/compare/ 34 | .. _good commit message: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/js/controller/product-detail-ctrl.js: -------------------------------------------------------------------------------- 1 | PriceMonitorApp.controller('ProductDetailCtrl', function ($scope, $routeParams, $location, $modal, Product) { 2 | $scope.siteName = SETTINGS.siteName; 3 | $scope.product = Product.get( 4 | {asin: $routeParams.asin}, 5 | // called when product can be retrieved 6 | function () { 7 | $scope.open = function () { 8 | var modalInstance = $modal.open({ 9 | templateUrl: SETTINGS.uris.static + '/price_monitor/app/partials/product-delete.html', 10 | controller: 'ProductDeleteCtrl', 11 | resolve: { 12 | product: function () { 13 | return $scope.product; 14 | } 15 | } 16 | }); 17 | 18 | modalInstance.result.then(function () { 19 | $location.path('#products'); 20 | }); 21 | }; 22 | }, 23 | // called if asin is not found 24 | function () { 25 | $location.path('#products'); 26 | } 27 | ); 28 | 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /price_monitor/models/mixins/PublicIDMixin.py: -------------------------------------------------------------------------------- 1 | """Mixin for having a public id.""" 2 | from django.db import models 3 | from django.utils.translation import ugettext as _ 4 | 5 | from uuid import uuid4 6 | 7 | 8 | class PublicIDMixin(models.Model): 9 | 10 | """Mixin for adding a public id to models to prevent revealing database ids via API""" 11 | 12 | public_id = models.CharField( 13 | max_length=36, 14 | unique=True, 15 | editable=False, 16 | null=False, 17 | db_index=True, 18 | verbose_name=_('Public-ID') 19 | ) 20 | 21 | def save(self, *args, **kwargs): 22 | """ 23 | Sets public id on new instances 24 | 25 | :param args: positional arguments 26 | :type args: list 27 | :param kwargs: keyword arguments 28 | :type kwargs: dict 29 | :returns: what parent returns 30 | :rtype: see parent 31 | """ 32 | if self.pk is None: 33 | self.public_id = str(uuid4()) 34 | return super(PublicIDMixin, self).save(*args, **kwargs) 35 | 36 | class Meta(object): 37 | 38 | """Meta stuff""" 39 | 40 | abstract = True 41 | app_label = 'price_monitor' 42 | -------------------------------------------------------------------------------- /price_monitor/management/commands/price_monitor_batch_create_products.py: -------------------------------------------------------------------------------- 1 | """Management command for batch reation of products""" 2 | from django.core.management.base import BaseCommand 3 | 4 | from price_monitor.models import Product 5 | 6 | 7 | class Command(BaseCommand): 8 | 9 | """Command for batch creating of products.""" 10 | 11 | help = 'Creates multiple products from the given ASIN list. Skips products already in database.' 12 | 13 | def add_arguments(self, parser): 14 | """ 15 | Adds the positional argument for ASINs 16 | 17 | :param parser: the argument parser 18 | """ 19 | parser.add_argument('asins', nargs='+', type=str) 20 | 21 | def handle(self, *args, **options): 22 | """Batch create products from given ASIN list.""" 23 | # get all products with given asins 24 | product_asins = [p.asin for p in Product.objects.filter(asin__in=options['asins'])] 25 | 26 | # remove the asins that are already there 27 | asins = [a for a in options['asins'] if a not in product_asins] 28 | 29 | # create some products 30 | for asin in asins: 31 | Product.objects.create(asin=asin) 32 | 33 | print('created {0:d} products'.format(len(asins))) 34 | -------------------------------------------------------------------------------- /price_monitor/models/EmailNotification.py: -------------------------------------------------------------------------------- 1 | """Model for an email based notification""" 2 | from .mixins.PublicIDMixin import PublicIDMixin 3 | 4 | from django.conf import settings 5 | from django.db import models 6 | from django.utils.translation import ugettext as _, ugettext_lazy 7 | 8 | from six import text_type 9 | 10 | 11 | class EmailNotification(PublicIDMixin, models.Model): 12 | 13 | """An email notification.""" 14 | 15 | owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_('Owner')) 16 | email = models.EmailField(verbose_name=_('Email address')) 17 | 18 | def __str__(self): 19 | """ 20 | Returns the unicode representation of the EmailNotification. 21 | 22 | :return: the unicode representation 23 | :rtype: unicode 24 | """ 25 | return text_type( 26 | ' {email!s}'.format(**{ 27 | 'email': self.email, 28 | }) 29 | ) 30 | 31 | class Meta(object): 32 | 33 | """Meta Peter or how to configure your Django model""" 34 | 35 | app_label = 'price_monitor' 36 | verbose_name = ugettext_lazy('Email Notification') 37 | verbose_name_plural = ugettext_lazy('Email Notifications') 38 | ordering = ('email',) 39 | -------------------------------------------------------------------------------- /price_monitor/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .views.EmailNotificationListView import EmailNotificationListView 4 | from .views.PriceListView import PriceListView 5 | from .views.ProductListView import ProductListView 6 | from .views.ProductCreateRetrieveUpdateDestroyAPIView import ProductCreateRetrieveUpdateDestroyAPIView 7 | from .views.SubscriptionRetrieveView import SubscriptionRetrieveView 8 | from .views.SubscriptionListView import SubscriptionListView 9 | 10 | 11 | urlpatterns = [ 12 | url(r'^email-notifications/$', EmailNotificationListView.as_view(), name='api_email_notification_list'), 13 | url(r'^products/(?P[0-9a-zA-Z_-]+)/prices/$', PriceListView.as_view(), name='api_product_price_list'), 14 | url(r'^products/(?P[0-9a-zA-Z_-]+)/$', ProductCreateRetrieveUpdateDestroyAPIView.as_view(), name='api_product_retrieve'), 15 | url(r'^products/$', ProductListView.as_view(), name='api_product_list'), 16 | url( 17 | r'^subscriptions/(?P(:public_id|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}))/$', 18 | SubscriptionRetrieveView.as_view(), 19 | name='api_subscription_retrieve' 20 | ), 21 | url(r'^subscriptions/$', SubscriptionListView.as_view(), name='api_subscription_list'), 22 | ] 23 | -------------------------------------------------------------------------------- /price_monitor/tasks.py: -------------------------------------------------------------------------------- 1 | """General tasks""" 2 | import logging 3 | 4 | from celery.task import Task 5 | 6 | from price_monitor.models import ( 7 | Price, 8 | Product, 9 | Subscription, 10 | ) 11 | 12 | 13 | logger = logging.getLogger('price_monitor.tasks') 14 | 15 | 16 | class ProductCleanupTask(Task): 17 | 18 | """Task for removing a product if it has no subscribers.""" 19 | 20 | def run(self, asin): 21 | """ 22 | Checks if there are subscribers for the product with the given asin. If not, the product and its prices are deleted. 23 | 24 | :param asin: the ASIN of the product 25 | :type asin: str 26 | :return: success or failure 27 | """ 28 | try: 29 | product = Product.objects.get(asin=asin) 30 | except Product.DoesNotExist: 31 | logger.error('Product with ASIN %d does not exist, skipping ProductCleanupTask', asin) 32 | return 33 | 34 | subscribers = Subscription.objects.filter(product=product).count() 35 | 36 | if subscribers == 0: 37 | prices = Price.objects.filter(product=product) 38 | logger.info('Removing product with ASIN %s (PK: %d) and its %d prices', asin, product.pk, prices.count()) 39 | prices.delete() 40 | product.delete() 41 | return True 42 | -------------------------------------------------------------------------------- /price_monitor/api/views/EmailNotificationListView.py: -------------------------------------------------------------------------------- 1 | """View for listing email notifications""" 2 | from ..serializers.EmailNotificationSerializer import EmailNotificationSerializer 3 | from ...models.EmailNotification import EmailNotification 4 | 5 | from rest_framework import generics, mixins, permissions 6 | 7 | 8 | class EmailNotificationListView(mixins.CreateModelMixin, generics.ListAPIView): 9 | 10 | """View for rendering list of EmailNotification objects""" 11 | 12 | model = EmailNotification 13 | serializer_class = EmailNotificationSerializer 14 | permission_classes = [ 15 | # only return the list if user is authenticated 16 | permissions.IsAuthenticated 17 | ] 18 | 19 | def post(self, request, *args, **kwargs): 20 | """ 21 | Add post method to create object 22 | 23 | :param request: the request 24 | :type request: HttpRequest 25 | :return: Result of creation 26 | :rtype: HttpResponse 27 | """ 28 | return self.create(request, *args, **kwargs) 29 | 30 | def get_queryset(self): 31 | """ 32 | Filters queryset by the authenticated user 33 | 34 | :returns: filtered EmailNotification objects 35 | :rtype: QuerySet 36 | """ 37 | return self.model.objects.filter(owner=self.request.user) 38 | -------------------------------------------------------------------------------- /price_monitor/models/Price.py: -------------------------------------------------------------------------------- 1 | """Definition of a model for prices""" 2 | from django.db import models 3 | from django.utils.translation import ugettext as _, ugettext_lazy 4 | 5 | 6 | class Price(models.Model): 7 | 8 | """Representing fetched price for a product""" 9 | 10 | value = models.FloatField(verbose_name=_('Price'), blank=True, null=True) 11 | currency = models.CharField(max_length=3, verbose_name=_('Currency'), blank=True, null=True) 12 | date_seen = models.DateTimeField(verbose_name=_('Date of price')) 13 | product = models.ForeignKey('Product', on_delete=models.CASCADE, verbose_name=_('Product')) 14 | 15 | def __str__(self): 16 | """ 17 | Returns the string representation of the Product. 18 | 19 | :return: the unicode representation 20 | :rtype: unicode 21 | """ 22 | return '{value!s} {currency!s} on {date_seen!s}'.format(**dict( 23 | value='{0:0.2f}'.format(self.value) if self.value else 'No price', 24 | currency=self.currency if self.currency else '', 25 | date_seen=self.date_seen 26 | )) 27 | 28 | class Meta(object): 29 | app_label = 'price_monitor' 30 | get_latest_by = 'date_seen' 31 | verbose_name = ugettext_lazy('Price') 32 | verbose_name_plural = ugettext_lazy('Prices') 33 | ordering = ('date_seen',) 34 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/css/xeditable.css: -------------------------------------------------------------------------------- 1 | /*! 2 | angular-xeditable - 0.1.8 3 | Edit-in-place for angular.js 4 | Build date: 2014-01-10 5 | */ 6 | 7 | .editable-wrap{display:inline-block;white-space:nowrap;margin:0}.editable-wrap .editable-controls,.editable-wrap .editable-error{margin-bottom:0}.editable-wrap .editable-controls>input,.editable-wrap .editable-controls>select,.editable-wrap .editable-controls>textarea{margin-bottom:0}.editable-wrap .editable-input{display:inline-block}.editable-buttons{display:inline-block;vertical-align:top}.editable-buttons button{margin-left:5px}.editable-input.editable-has-buttons{width:auto}.editable-bstime .editable-input input[type=text]{width:46px}.editable-bstime .well-small{margin-bottom:0;padding:10px}.editable-range output{display:inline-block;min-width:30px;vertical-align:top;text-align:center}.editable-color input[type=color]{width:50px}.editable-checkbox label span,.editable-checklist label span,.editable-radiolist label span{margin-left:7px;margin-right:10px}.editable-hide{display:none!important}.editable-click,a.editable-click{text-decoration:none;color:#428bca;border-bottom:dashed 1px #428bca}.editable-click:hover,a.editable-click:hover{text-decoration:none;color:#2a6496;border-bottom-color:#2a6496}.editable-empty,.editable-empty:hover,.editable-empty:focus,a.editable-empty,a.editable-empty:hover,a.editable-empty:focus{font-style:italic;color:#D14;text-decoration:none} -------------------------------------------------------------------------------- /docker/web/django-amazon-price-monitor/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Setup file for the django-amazon-price-monitor package.""" 3 | try: 4 | from setuptools import setup 5 | except ImportError: 6 | from distutils.core import setup 7 | 8 | 9 | readme = "" 10 | history = "" 11 | 12 | setup( 13 | name='django-amazon-price-monitor', 14 | version=__import__('price_monitor').get_version().replace(' ', '-'), 15 | description='Monitors prices of Amazon products via Product Advertising API', 16 | long_description=readme + '\n\n' + history, 17 | author='Alexander Herrmann, Martin Mrose', 18 | author_email='django-amazon-price-monitor@googlegroups.com', 19 | url='https://github.com/ponyriders/django-amazon-price-monitor', 20 | packages=[ 21 | 'price_monitor' 22 | ], 23 | include_package_data=True, 24 | install_requires=[ 25 | # main dependencies 26 | 'Django>=1.8,<2', 27 | # for product advertising api 28 | 'beautifulsoup4<=4.6', 29 | 'bottlenose>=0.6.2,<1.2', 30 | 'celery>=4,<4.1', 31 | 'python-dateutil>=2.5.1,<2.7', 32 | 'kombu>=4.1.0,<4.2', 33 | # for pm api 34 | 'djangorestframework>=3.3,<3.7', 35 | # for graphs 36 | 'pygal>=2.0.7,<2.5', 37 | 'lxml>=4,<4.1', 38 | # pygal png output 39 | 'CairoSVG>=2,<2.1', 40 | 'tinycss>=0.4,<0.5', 41 | 'cssselect>=1.0.1,<1.1', 42 | ], 43 | license='MIT', 44 | zip_safe=False, 45 | ) 46 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Setup file for the django-amazon-price-monitor package.""" 3 | try: 4 | from setuptools import setup 5 | except ImportError: 6 | from distutils.core import setup 7 | 8 | 9 | readme = open('README.rst').read() 10 | history = open('HISTORY.rst').read() 11 | 12 | setup( 13 | name='django-amazon-price-monitor', 14 | version=__import__('price_monitor').get_version().replace(' ', '-'), 15 | description='Monitors prices of Amazon products via Product Advertising API', 16 | long_description=readme + '\n\n' + history, 17 | author='Alexander Herrmann, Martin Mrose', 18 | author_email='django-amazon-price-monitor@googlegroups.com', 19 | url='https://github.com/ponyriders/django-amazon-price-monitor', 20 | packages=[ 21 | 'price_monitor' 22 | ], 23 | include_package_data=True, 24 | install_requires=[ 25 | # main dependencies 26 | 'Django>=1.8,<2', 27 | # for product advertising api 28 | 'beautifulsoup4<=4.6', 29 | 'bottlenose>=0.6.2,<1.2', 30 | 'celery>=4,<4.1', 31 | 'python-dateutil>=2.5.1,<2.7', 32 | 'kombu>=4.1.0,<4.2', 33 | # for pm api 34 | 'djangorestframework>=3.3,<3.7', 35 | # for graphs 36 | 'pygal>=2.0.7,<2.5', 37 | 'lxml>=4,<4.1', 38 | # pygal png output 39 | 'CairoSVG>=2,<2.1', 40 | 'tinycss>=0.4,<0.5', 41 | 'cssselect>=1.0.1,<1.1', 42 | ], 43 | license='MIT', 44 | zip_safe=False, 45 | ) 46 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/js/server-connector.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var PriceMonitorServerConnector = angular.module('PriceMonitorServerConnector', ['ngResource', 'djangoRESTResources']); 4 | 5 | PriceMonitorServerConnector.factory('Product', ['djResource', function(djResource) { 6 | var Product = djResource(SETTINGS.uris.product, {'asin': '@asin'}, { 7 | 'update': { 8 | method:'PUT' 9 | } 10 | }); 11 | 12 | Product.prototype.getSparklineUrl = function() { 13 | return SETTINGS.uris.sparkline.replace(':asin', this.asin); 14 | }; 15 | 16 | Product.prototype.getChartUrl = function(size) { 17 | if (SETTINGS.uris.chart[size]) { 18 | return SETTINGS.uris.chart[size].replace(':asin', this.asin) 19 | } 20 | return ''; 21 | }; 22 | 23 | Product.prototype.removeSubscription = function(index) { 24 | this.subscription_set.splice(index, 1); 25 | }; 26 | 27 | return Product; 28 | }]); 29 | 30 | PriceMonitorServerConnector.factory('Subscription', ['djResource', 'Product', function(djResource) { 31 | return djResource(SETTINGS.uris.subscription, {'public_id': '@public_id'}, {}); 32 | }]); 33 | 34 | PriceMonitorServerConnector.factory('Price', ['djResource', function(djResource) { 35 | return djResource(SETTINGS.uris.price, {'asin': '@asin'}, {}); 36 | }]); 37 | 38 | PriceMonitorServerConnector.factory('EmailNotification', ['djResource', function(djResource) { 39 | return djResource(SETTINGS.uris.emailNotification, {}, {}); 40 | }]); 41 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/js/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var PriceMonitorApp = angular.module( 4 | 'PriceMonitorApp', 5 | [ 6 | 'ngCookies', 7 | 'ngRoute', 8 | 'ngResource', 9 | 'ui.bootstrap', 10 | 'djangoRESTResources', 11 | 'ngResponsiveImages', 12 | 'xeditable', 13 | 'PriceMonitorServerConnector' 14 | ] 15 | ); 16 | 17 | PriceMonitorApp.config(function ($routeProvider) { 18 | $routeProvider 19 | .when('/products', { 20 | controller: 'ProductListCtrl', 21 | templateUrl: SETTINGS.uris.static + 'price_monitor/app/partials/product-list.html' 22 | }) 23 | .when('/products/:asin', { 24 | controller: 'ProductDetailCtrl', 25 | templateUrl: SETTINGS.uris.static + 'price_monitor/app/partials/product-detail.html' 26 | }) 27 | .otherwise({redirectTo: '/products'}); 28 | }); 29 | 30 | /** 31 | * Setting X-Requested-With header to enable Django to identify the request as asyncronous. 32 | */ 33 | //PriceMonitorApp.config('$httpProvider', function($httpProvider) { 34 | // $httpProvider.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 35 | //}); 36 | 37 | /** 38 | * Adding value of CSRF cookie to request headers 39 | */ 40 | PriceMonitorApp.run(function($http, $cookies, editableOptions) { 41 | $http.defaults.headers.post['X-CSRFToken'] = $cookies.csrftoken; 42 | // Add the following two lines 43 | $http.defaults.xsrfCookieName = 'csrftoken'; 44 | $http.defaults.xsrfHeaderName = 'X-CSRFToken'; 45 | editableOptions.theme = 'bs3'; 46 | }); 47 | -------------------------------------------------------------------------------- /price_monitor/management/commands/price_monitor_send_test_mail.py: -------------------------------------------------------------------------------- 1 | """Management command for sending a pricemonitor specific test email""" 2 | from datetime import datetime 3 | 4 | from django.contrib.auth.models import User 5 | from django.core.management.base import BaseCommand 6 | 7 | from price_monitor.models import ( 8 | EmailNotification, 9 | Price, 10 | Product, 11 | Subscription, 12 | ) 13 | from price_monitor.utils import send_mail 14 | 15 | 16 | class Command(BaseCommand): 17 | 18 | """Command for sending a pricemonitor specific test email""" 19 | 20 | help = 'Sends a pricemonitor specific test email' 21 | 22 | def add_arguments(self, parser): 23 | """ 24 | Adds the positional argument for the email address. 25 | 26 | :param parser: the argument parser 27 | """ 28 | parser.add_argument('email', nargs='+', type=str) 29 | 30 | def handle(self, *args, **options): 31 | """Sends an email.""" 32 | 33 | u = User() 34 | 35 | e = EmailNotification() 36 | e.owner = u 37 | e.email = options['email'][0] 38 | 39 | p = Product() 40 | p.asin = 'ASIN123' 41 | p.title = 'Dummy Product' 42 | p.offer_url = 'http://localhost/offer' 43 | 44 | s = Subscription() 45 | s.price_limit = 9.99 46 | s.email_notification = e 47 | 48 | r = Price() 49 | r.value = 8.00 50 | r.currency = 'EUR' 51 | r.date_seen = datetime.now() 52 | r.product = p 53 | 54 | send_mail( 55 | p, 56 | s, 57 | r, 58 | additional_text='This is a test email.' 59 | ) 60 | -------------------------------------------------------------------------------- /price_monitor/management/commands/price_monitor_clean_db.py: -------------------------------------------------------------------------------- 1 | """Management command for removing invalid data from database""" 2 | from django.core.management.base import BaseCommand 3 | 4 | from price_monitor.models import ( 5 | Price, 6 | Product, 7 | ) 8 | 9 | 10 | class Command(BaseCommand): 11 | 12 | """Command for cleaning the database. Deletes all products without subscriptions.""" 13 | 14 | help = 'Deletes all products without subscriptions' 15 | 16 | def handle(self, *args, **options): 17 | """Deletes the products without subscriptions.""" 18 | products_without_subscribers = Product.objects.filter(subscribers__isnull=True) 19 | prices_without_subscribers = Price.objects.filter(product__subscribers__isnull=True) 20 | 21 | print('=== PRE-CLEANUP ==================================') 22 | print('Product count: {0:20d}'.format(Product.objects.count())) 23 | print('Products with subscribers: {0:20d}'.format(Product.objects.filter(subscribers__isnull=False).count())) 24 | print('Products without subscribers: {0:20d}'.format(products_without_subscribers.count())) 25 | print('Prices count: {0:20d}'.format(Price.objects.count())) 26 | print('==================================================') 27 | print('') 28 | 29 | choice = input( 30 | '{0:d} products with {1:d} prices will be deleted, continue? [y/N]'.format( 31 | products_without_subscribers.count(), 32 | prices_without_subscribers.count() 33 | ) 34 | ) 35 | 36 | if choice in ['y', 'Y']: 37 | products_without_subscribers.delete() 38 | prices_without_subscribers.delete() 39 | print('') 40 | print('DONE') 41 | -------------------------------------------------------------------------------- /price_monitor/api/views/ProductCreateRetrieveUpdateDestroyAPIView.py: -------------------------------------------------------------------------------- 1 | """Mixed view for API""" 2 | from .mixins.ProductFilteringMixin import ProductFilteringMixin 3 | from ..serializers.ProductSerializer import ProductSerializer 4 | from ...models.Product import Product 5 | 6 | from rest_framework import generics, mixins, permissions 7 | 8 | 9 | class ProductCreateRetrieveUpdateDestroyAPIView(ProductFilteringMixin, mixins.CreateModelMixin, generics.RetrieveUpdateDestroyAPIView): 10 | 11 | """Returns single instance of Product, if user is authenticated""" 12 | 13 | model = Product 14 | serializer_class = ProductSerializer 15 | lookup_field = 'asin' 16 | permission_classes = [ 17 | # only return the product if user is authenticated 18 | permissions.IsAuthenticated 19 | ] 20 | 21 | def post(self, request, *args, **kwargs): 22 | """ 23 | Add post method to create object 24 | 25 | :param request: the request 26 | :type request: HttpRequest 27 | :return: Result of creation 28 | :rtype: HttpResponse 29 | """ 30 | return self.create(request, *args, **kwargs) 31 | 32 | def get_queryset(self): 33 | """ 34 | Filters queryset by the authenticated user 35 | 36 | :returns: filtered Product objects 37 | :rtype: QuerySet 38 | """ 39 | # distinct is needed to prevent multiple instances of product in resultset if multiple subscriptions are present 40 | return self.model.objects.filter(subscription__owner=self.request.user).distinct() 41 | 42 | def perform_destroy(self, instance): 43 | """ 44 | Overwrite base function to delete subscriptions, not the product itself 45 | 46 | :param instance: the product to delete subscriptions from 47 | :type instance: Product 48 | """ 49 | instance.subscription_set.filter(owner=self.request.user).delete() 50 | -------------------------------------------------------------------------------- /price_monitor/models/Subscription.py: -------------------------------------------------------------------------------- 1 | """The subscription model""" 2 | from .mixins.PublicIDMixin import PublicIDMixin 3 | 4 | from django.conf import settings 5 | from django.db import models 6 | from django.utils.translation import ugettext as _, ugettext_lazy 7 | 8 | 9 | class Subscription(PublicIDMixin, models.Model): 10 | 11 | """Model for a user being able to subscribe to a product and be notified if the price_limit is reached.""" 12 | 13 | owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_('Owner')) 14 | product = models.ForeignKey('Product', on_delete=models.CASCADE, verbose_name=_('Product')) 15 | price_limit = models.FloatField(verbose_name=_('Price limit')) 16 | date_last_notification = models.DateTimeField(null=True, blank=True, verbose_name=_('Date of last sent notification')) 17 | email_notification = models.ForeignKey('EmailNotification', on_delete=models.CASCADE, verbose_name=_('Email Notification')) 18 | 19 | def get_email_address(self): 20 | """ 21 | Returns the email address of the notification. 22 | 23 | :return: string 24 | """ 25 | return self.email_notification.email 26 | 27 | get_email_address.short_description = ugettext_lazy('Notification email') 28 | 29 | def __str__(self): 30 | """ 31 | Returns the string representation of the Subscription. 32 | 33 | :return: the unicode representation 34 | :rtype: unicode 35 | """ 36 | return 'Subscription of "{product!s}" for {user!s}'.format(**{ 37 | 'product': self.product.title, 38 | 'user': self.owner.username, 39 | }) 40 | 41 | class Meta(object): 42 | 43 | """Meta stuff - you know what...""" 44 | 45 | app_label = 'price_monitor' 46 | verbose_name = ugettext_lazy('Subscription') 47 | verbose_name_plural = ugettext_lazy('Subscriptions') 48 | ordering = ('product__title', 'price_limit', 'email_notification__email',) 49 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py34-django1.8, 4 | py34-django1.9, 5 | py34-django1.10, 6 | py34-django1.11, 7 | py35-django1.9, 8 | py35-django1.10, 9 | py35-django1.11, 10 | py36-django1.11 11 | 12 | [testenv] 13 | setenv = 14 | STAGE = TravisCI 15 | DJANGO_SETTINGS_MODULE = tests.settings 16 | PYTHONPATH = {toxinidir}:{toxinidir}/price_monitor 17 | SECRET_KEY = 'F(fxm_9aKa9F_7e$!U1can%;%qc9A[.Jcx2lVCwWo3}*DL,y?H' 18 | AWS_ACCESS_KEY_ID = '' 19 | AWS_SECRET_ACCESS_KEY = '' 20 | commands = 21 | py.test --cov=price_monitor --ds tests.settings 22 | 23 | [base] 24 | deps = 25 | pytest<=3.2 26 | pytest-cov<=2.5 27 | pytest-pep8<1.1 28 | pytest-flakes<=2.0 29 | pytest-sugar<=0.9 30 | pytest-django<=3.1 31 | testfixtures<=5.2 32 | 33 | [testenv:py34-django1.8] 34 | basepython = python3.4 35 | deps = 36 | django>=1.8,<1.9 37 | {[base]deps} 38 | 39 | [testenv:py34-django1.9] 40 | basepython = python3.4 41 | deps = 42 | django>=1.9,<1.10 43 | {[base]deps} 44 | 45 | [testenv:py34-django1.10] 46 | basepython = python3.4 47 | deps = 48 | django>=1.10,<1.11 49 | {[base]deps} 50 | 51 | [testenv:py34-django1.11] 52 | basepython = python3.4 53 | deps = 54 | django>=1.11,<2 55 | {[base]deps} 56 | 57 | [testenv:py35-django1.9] 58 | basepython = python3.5 59 | deps = 60 | django>=1.9,<1.10 61 | {[base]deps} 62 | 63 | [testenv:py35-django1.10] 64 | basepython = python3.5 65 | deps = 66 | django>=1.10,<1.11 67 | {[base]deps} 68 | 69 | [testenv:py35-django1.11] 70 | basepython = python3.5 71 | deps = 72 | django>=1.11,<2 73 | {[base]deps} 74 | 75 | [testenv:py36-django1.11] 76 | basepython = python3.6 77 | deps = 78 | django>=1.11,<2 79 | {[base]deps} 80 | 81 | [pytest] 82 | addopts = 83 | --pep8 --flakes 84 | norecursedirs = 85 | .cache 86 | .git 87 | .env 88 | .tox 89 | docker/logs 90 | docker/media 91 | docker/postgres 92 | docs 93 | pep8maxlinelength = 160 94 | pep8ignore = 95 | docs/*.py ALL -------------------------------------------------------------------------------- /price_monitor/forms.py: -------------------------------------------------------------------------------- 1 | """Form definitions for frontend""" 2 | from . import app_settings as settings 3 | from .models.EmailNotification import EmailNotification 4 | from .models.Product import Product 5 | from .models.Subscription import Subscription 6 | 7 | from django import forms 8 | from django.utils.translation import ugettext as _ 9 | 10 | 11 | class SubscriptionCreationForm(forms.ModelForm): 12 | 13 | """Form for creating an product Subscription""" 14 | 15 | product = forms.RegexField(label=_('ASIN'), regex=settings.PRICE_MONITOR_ASIN_REGEX) 16 | email_notification = forms.ModelChoiceField(queryset=EmailNotification.objects.all(), empty_label=None) 17 | 18 | def clean_product(self): 19 | """ 20 | At creation, user gives an ASIN. But for saving the model, a product instance is needed. 21 | 22 | So this product is looked up or created if not present here. 23 | """ 24 | asin = self.cleaned_data['product'] 25 | try: 26 | product = Product.objects.get(asin__iexact=asin) 27 | except Product.DoesNotExist: 28 | product = Product.objects.create(asin=asin) 29 | asin = product 30 | return asin 31 | 32 | class Meta(object): 33 | 34 | """Form meta stuff""" 35 | 36 | fields = ('product', 'email_notification', 'price_limit', 'owner') 37 | model = Subscription 38 | widgets = { 39 | 'owner': forms.HiddenInput(), 40 | } 41 | 42 | 43 | class SubscriptionUpdateForm(forms.ModelForm): 44 | 45 | """Form for updating a subscription""" 46 | 47 | class Meta(object): 48 | 49 | """Form meta stuff""" 50 | 51 | fields = ('product', 'email_notification', 'price_limit', 'owner') 52 | model = Subscription 53 | widgets = { 54 | 'owner': forms.HiddenInput(), 55 | 'product': forms.TextInput(attrs={'readonly': True}), 56 | } 57 | 58 | 59 | class EmailNotificationForm(forms.ModelForm): 60 | 61 | """Form for giving an email notification""" 62 | 63 | class Meta(object): 64 | 65 | """Form meta stuff""" 66 | 67 | fields = ('email', 'owner') 68 | model = EmailNotification 69 | widgets = { 70 | 'owner': forms.HiddenInput(), 71 | } 72 | -------------------------------------------------------------------------------- /price_monitor/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from django.contrib.auth.decorators import login_required 5 | from django.http import HttpResponse 6 | from django.utils.decorators import method_decorator 7 | from django.views.generic import TemplateView 8 | 9 | from . import app_settings 10 | from .forms import SubscriptionCreationForm 11 | 12 | logger = logging.getLogger('price_monitor') 13 | 14 | 15 | class AngularIndexView(TemplateView): 16 | template_name = 'price_monitor/angular_index_view.html' 17 | form = SubscriptionCreationForm 18 | 19 | @method_decorator(login_required) 20 | def dispatch(self, *args, **kwargs): 21 | """ 22 | Overwriting this method the make every instance of the view 23 | login_required 24 | :param args: positional arguments 25 | :type args: List 26 | :param kwargs: keyword arguments 27 | :type kwargs: Dict 28 | :return: Result of super method. As this dispatches the handling method 29 | for the incoming request and calls it, the return is a HttpResponse 30 | object 31 | :rtype: HttpResponse 32 | """ 33 | return super(AngularIndexView, self).dispatch(*args, **kwargs) 34 | 35 | def get_context_data(self, form=None, **kwargs): 36 | context = super(AngularIndexView, self).get_context_data(**kwargs) 37 | context.update( 38 | default_currency=app_settings.PRICE_MONITOR_DEFAULT_CURRENCY, 39 | subscription_create_form=form, 40 | site_name=app_settings.PRICE_MONITOR_SITENAME, 41 | product_advertising_disclaimer=app_settings.PRICE_MONITOR_AMAZON_PRODUCT_ADVERTISING_API_DISCLAIMER, 42 | associate_disclaimer=app_settings.PRICE_MONITOR_ASSOCIATE_DISCLAIMER, 43 | ) 44 | return context 45 | 46 | def get(self, request, **kwargs): 47 | form = self.form() 48 | context = self.get_context_data(form=form, **kwargs) 49 | return self.render_to_response(context) 50 | 51 | def post(self, request, **kwargs): 52 | in_data = json.loads(request.body) 53 | form = self.form(data=in_data) 54 | response_data = {'errors': form.errors} 55 | return HttpResponse(json.dumps(response_data), content_type="application/json") 56 | -------------------------------------------------------------------------------- /price_monitor/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Base module for models that are in module entities. Sets all signal handlers""" 2 | import os 3 | 4 | from django.db.models.signals import ( 5 | post_delete, 6 | post_save, 7 | ) 8 | from django.dispatch import receiver 9 | 10 | from price_monitor.models.EmailNotification import EmailNotification # noqa 11 | from price_monitor.models.Price import Price # noqa 12 | from price_monitor.models.Product import Product # noqa 13 | from price_monitor.models.Subscription import Subscription # noqa 14 | 15 | 16 | @receiver(post_save, sender=Product) 17 | def synchronize_product_after_creation(sender, instance, created, **kwargs): # pylint:disable=unused-argument 18 | """ 19 | Directly start synchronization of a Product after its creation. 20 | 21 | :param sender: class calling the signal 22 | :type sender: ModelBase 23 | :param instance: the Product instance 24 | :type instance: Product 25 | :param created: if the Product was created 26 | :type created: bool 27 | :param kwargs: additional keywords arguments, see https://docs.djangoproject.com/en/dev/ref/signals/#django.db.models.signals.post_save 28 | :type kwargs: dict 29 | """ 30 | if created and os.environ.get('STAGE', 'Live') != 'TravisCI': 31 | from price_monitor.product_advertising_api.tasks import SynchronizeProductsTask 32 | # 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 33 | SynchronizeProductsTask.apply_async(([instance.asin],), countdown=1) 34 | 35 | 36 | @receiver(post_delete, sender=Subscription) 37 | def cleanup_products_after_subscription_removal(sender, instance, using, **kwargs): # pylint:disable=unused-argument 38 | """ 39 | Queues the execution of the ProductCleanupTask after a subscription was deleted. 40 | 41 | :param sender: class calling the signal 42 | :type sender: ModelBase 43 | :param instance: the Subscription instance 44 | :type instance: price_monitor.models.Subscription 45 | :param using: database alias being used 46 | :type using: str 47 | :param kwargs: additional keywords arguments, see https://docs.djangoproject.com/en/dev/ref/signals/#django.db.models.signals.post_delete 48 | :type kwargs: dict 49 | """ 50 | if os.environ.get('STAGE', 'Live') != 'TravisCI': 51 | from price_monitor.tasks import ProductCleanupTask 52 | ProductCleanupTask.delay(instance.product.asin) 53 | -------------------------------------------------------------------------------- /price_monitor/admin.py: -------------------------------------------------------------------------------- 1 | """AdminSite definitions""" 2 | from django.contrib import admin 3 | from django.utils.translation import ugettext_lazy 4 | 5 | from price_monitor.models import ( 6 | EmailNotification, 7 | Price, 8 | Product, 9 | Subscription, 10 | ) 11 | 12 | 13 | class PriceAdmin(admin.ModelAdmin): 14 | 15 | """Admin for the model Price""" 16 | 17 | list_display = ('date_seen', 'value', 'currency', ) 18 | list_filter = ('product', ) 19 | 20 | 21 | class ProductAdmin(admin.ModelAdmin): 22 | 23 | """Admin for the model Product""" 24 | 25 | list_display = ('asin', 'title', 'artist', 'audience_rating', 'status', 'date_updated', 'date_last_synced', ) 26 | list_filter = ('status', 'audience_rating', ) 27 | search_fields = ('asin', ) 28 | readonly_fields = ('current_price', 'highest_price', 'lowest_price',) 29 | 30 | actions = ['reset_to_created', 'resynchronize', ] 31 | 32 | def reset_to_created(self, request, queryset): # pylint:disable=unused-argument 33 | """ 34 | Resets the status of the product back to created. 35 | :param request: sent request 36 | :param queryset: queryset containing the products 37 | """ 38 | queryset.update(status=0) 39 | reset_to_created.short_description = ugettext_lazy('Reset to status "Created".') 40 | 41 | def resynchronize(self, request, queryset): # pylint:disable=unused-argument 42 | """ 43 | Synchronizes the sent products with the product advertising api. 44 | :param request: sent request 45 | :param queryset: queryset containing the products 46 | """ 47 | from price_monitor.product_advertising_api.tasks import SynchronizeProductsTask 48 | for product in queryset: 49 | SynchronizeProductsTask.delay([product.asin]) 50 | resynchronize.short_description = ugettext_lazy('Resynchronize with API') 51 | 52 | 53 | class SubscriptionAdmin(admin.ModelAdmin): 54 | 55 | """Admin for the model Subscription""" 56 | 57 | list_display = ('product', 'price_limit', 'owner', 'date_last_notification', 'get_email_address', 'public_id',) 58 | list_filter = ('owner__username', 'price_limit', ) 59 | 60 | 61 | class EmailNotificationAdmin(admin.ModelAdmin): 62 | 63 | """Admin for the model EmailNotification""" 64 | 65 | list_display = ('email', 'owner', 'public_id',) 66 | 67 | 68 | admin.site.register(Price, PriceAdmin) 69 | admin.site.register(Product, ProductAdmin) 70 | admin.site.register(Subscription, SubscriptionAdmin) 71 | admin.site.register(EmailNotification, EmailNotificationAdmin) 72 | -------------------------------------------------------------------------------- /tests/test_product.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from price_monitor import app_settings 4 | from price_monitor.models import Product 5 | 6 | 7 | class ProductTest(TestCase): 8 | 9 | def test_set_failed_to_sync(self): 10 | asin = 'ASINASINASIN' 11 | p = Product.objects.create(asin=asin) 12 | self.assertIsNotNone(p) 13 | self.assertEqual(asin, p.asin) 14 | self.assertEqual(0, p.status) 15 | 16 | p.set_failed_to_sync() 17 | self.assertEqual(2, p.status) 18 | 19 | def test_get_image_urls(self): 20 | """Tests the Product.get_image_urls method.""" 21 | 22 | # FIXME usually you would test a HTTP and a HTTPS setup but override_settings does not work with our app_settings (the setting does not get overwritten) 23 | 24 | # no images set 25 | p = Product.objects.create( 26 | asin='ASIN0000001', 27 | ) 28 | self.assertEqual(3, len(p.get_image_urls())) 29 | self.assertTrue('small' in p.get_image_urls()) 30 | self.assertTrue('medium' in p.get_image_urls()) 31 | self.assertTrue('large' in p.get_image_urls()) 32 | self.assertEqual(app_settings.PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN, p.get_image_urls()['small']) 33 | self.assertEqual(app_settings.PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN, p.get_image_urls()['medium']) 34 | self.assertEqual(app_settings.PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN, p.get_image_urls()['large']) 35 | 36 | # all images set 37 | p = Product.objects.create( 38 | asin='ASIN0000002', 39 | small_image_url='http://github.com/ponyriders/django-amazon-price-monitor/small.png', 40 | medium_image_url='http://github.com/ponyriders/django-amazon-price-monitor/medium.png', 41 | large_image_url='http://github.com/ponyriders/django-amazon-price-monitor/large.png', 42 | ) 43 | self.assertEqual(3, len(p.get_image_urls())) 44 | self.assertTrue('small' in p.get_image_urls()) 45 | self.assertTrue('medium' in p.get_image_urls()) 46 | self.assertTrue('large' in p.get_image_urls()) 47 | self.assertEqual( 48 | '{0}{1}'.format(app_settings.PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN, '/ponyriders/django-amazon-price-monitor/small.png'), 49 | p.get_image_urls()['small'] 50 | ) 51 | self.assertEqual( 52 | '{0}{1}'.format(app_settings.PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN, '/ponyriders/django-amazon-price-monitor/medium.png'), 53 | p.get_image_urls()['medium'] 54 | ) 55 | self.assertEqual( 56 | '{0}{1}'.format(app_settings.PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN, '/ponyriders/django-amazon-price-monitor/large.png'), 57 | p.get_image_urls()['large'] 58 | ) 59 | 60 | def test_get_detail_url(self): 61 | """Tests the get_detail_url method""" 62 | p = Product.objects.create( 63 | asin='ASIN0054321', 64 | ) 65 | assert p.get_detail_url() == str(app_settings.PRICE_MONITOR_BASE_URL + '/#/products/ASIN0054321') 66 | -------------------------------------------------------------------------------- /price_monitor/utils.py: -------------------------------------------------------------------------------- 1 | """Several util functions""" 2 | import logging 3 | 4 | from django.core.mail import send_mail as django_send_mail 5 | from django.utils.translation import ugettext as _ 6 | 7 | from price_monitor import app_settings 8 | 9 | 10 | logger = logging.getLogger('price_monitor.utils') 11 | 12 | 13 | def get_offer_url(asin): 14 | """ 15 | Returns the offer url for an ASIN. 16 | 17 | :param asin: the asin 18 | :type asin: basestring 19 | :return: the url to the offer 20 | :rtype: basestring 21 | """ 22 | return app_settings.PRICE_MONITOR_OFFER_URL.format(**{ 23 | 'domain': app_settings.PRICE_MONITOR_AMAZON_REGION_DOMAINS[app_settings.PRICE_MONITOR_AMAZON_PRODUCT_API_REGION], 24 | 'asin': asin, 25 | 'assoc_tag': app_settings.PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG, 26 | }) 27 | 28 | 29 | def get_product_detail_url(asin): 30 | """ 31 | Returns the url to a product detail view. 32 | 33 | As the frontend is AngularJS, we cannot use any Django reverse functionality. 34 | :param asin: the asin to use 35 | :return: the link 36 | """ 37 | return '{base_url:s}/#/products/{asin:s}'.format( 38 | base_url=app_settings.PRICE_MONITOR_BASE_URL, 39 | asin=asin, 40 | ) 41 | 42 | 43 | def send_mail(product, subscription, price, additional_text=''): 44 | """ 45 | Sends an email using the appropriate settings for formatting aso. 46 | 47 | :param product: the product 48 | :type product: price_monitor.models.Product 49 | :param subscription: the subscription 50 | :type subscription: price_monitor.models.Subscription 51 | :param price: the current price 52 | :type price: price_monitor.models.Price 53 | :param additional_text: additional text to include in mail 54 | :type additional_text: str 55 | """ 56 | django_send_mail( 57 | _(app_settings.PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_SUBJECT) % {'product': product.title}, 58 | _(app_settings.PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_BODY).format( 59 | price_limit=subscription.price_limit, 60 | currency=price.currency, 61 | price=price.value, 62 | price_date=price.date_seen.strftime('%b %d, %Y %H:%M %p %Z'), 63 | product_title=product.get_title(), 64 | url_product_amazon=product.offer_url, 65 | url_product_detail=product.get_detail_url(), 66 | additional_text=additional_text, 67 | ), 68 | app_settings.PRICE_MONITOR_EMAIL_SENDER, 69 | [subscription.email_notification.email], 70 | fail_silently=False, 71 | ) 72 | 73 | 74 | def chunk_list(the_list, chunk_size): 75 | """ 76 | Chunks a list. 77 | 78 | :param the_list: list to chunk 79 | :type the_list: list 80 | :param chunk_size: number of elements to be contained in each created chunk list 81 | :type chunk_size: int 82 | :return: generator object with the chunked lists 83 | :rtype: generator 84 | """ 85 | for i in range(0, len(the_list), chunk_size): 86 | yield the_list[i:i + chunk_size] 87 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/js/controller/product-list-ctrl.js: -------------------------------------------------------------------------------- 1 | PriceMonitorApp.controller('ProductListCtrl', function($scope, $modal, Product, EmailNotification) { 2 | $scope.products = Product.query(function() { 3 | $scope.emailNotifications = EmailNotification.query(function() { 4 | $scope.productCount = $scope.products.length; 5 | $scope.currentPage = 1; 6 | $scope.maxPageCount = SETTINGS.pagination.maxPageCount; 7 | $scope.itemsPerPage = SETTINGS.pagination.itemsPerPage; 8 | $scope.paginationBoundaryLinks = SETTINGS.pagination.paginationBoundaryLinks; 9 | $scope.paginationRotate = SETTINGS.pagination.paginationRotate; 10 | $scope.pagesTotal = 0; 11 | $scope.siteName = SETTINGS.siteName; 12 | 13 | var emptyProduct = { 14 | asin: null, 15 | subscription_set: [{ 16 | price_limit: null, 17 | email_notification: { 18 | email: $scope.emailNotifications.length > 0 ? $scope.emailNotifications[0].email : '' 19 | } 20 | }] 21 | }; 22 | 23 | $scope.newProducts = [angular.copy(emptyProduct)]; 24 | 25 | $scope.addNewProduct = function() { 26 | $scope.newProducts.push(emptyProduct); 27 | }; 28 | 29 | $scope.removeFormLine = function(product) { 30 | var index = $scope.newProducts.indexOf(product); 31 | if (index != -1) { 32 | $scope.newProducts.splice(index, 1); 33 | } 34 | }; 35 | 36 | $scope.saveNewProducts = function() { 37 | angular.forEach($scope.newProducts, function(newProduct) { 38 | Product.save(newProduct, function() { 39 | $scope.products = Product.query(); 40 | $scope.newProducts = [angular.copy(emptyProduct)]; 41 | }); 42 | }); 43 | }; 44 | 45 | $scope.openProductDelete = function (product) { 46 | var modalInstance = $modal.open({ 47 | templateUrl: SETTINGS.uris.static + '/price_monitor/app/partials/product-delete.html', 48 | controller: 'ProductDeleteCtrl', 49 | resolve: { 50 | product: function () { 51 | return product; 52 | } 53 | } 54 | }); 55 | 56 | modalInstance.result.then(function () { 57 | $scope.products = Product.query(); 58 | }); 59 | }; 60 | 61 | $scope.openEmailNotificationCreate = function() { 62 | var modalInstance = $modal.open({ 63 | templateUrl: SETTINGS.uris.static + '/price_monitor/app/partials/emailnotification-create.html', 64 | controller: 'EmailNotificationCreateCtrl' 65 | }); 66 | 67 | modalInstance.result.then(function () { 68 | $scope.emailNotifications = EmailNotification.query(); 69 | }); 70 | }; 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/angular/angular-resource.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | AngularJS v1.3.9 3 | (c) 2010-2014 Google, Inc. http://angularjs.org 4 | License: MIT 5 | */ 6 | (function(I,d,B){'use strict';function D(f,q){q=q||{};d.forEach(q,function(d,h){delete q[h]});for(var h in f)!f.hasOwnProperty(h)||"$"===h.charAt(0)&&"$"===h.charAt(1)||(q[h]=f[h]);return q}var w=d.$$minErr("$resource"),C=/^(\.[a-zA-Z_$][0-9a-zA-Z_$]*)+$/;d.module("ngResource",["ng"]).provider("$resource",function(){var f=this;this.defaults={stripTrailingSlashes:!0,actions:{get:{method:"GET"},save:{method:"POST"},query:{method:"GET",isArray:!0},remove:{method:"DELETE"},"delete":{method:"DELETE"}}}; 7 | this.$get=["$http","$q",function(q,h){function t(d,g){this.template=d;this.defaults=s({},f.defaults,g);this.urlParams={}}function v(x,g,l,m){function c(b,k){var c={};k=s({},g,k);r(k,function(a,k){u(a)&&(a=a());var d;if(a&&a.charAt&&"@"==a.charAt(0)){d=b;var e=a.substr(1);if(null==e||""===e||"hasOwnProperty"===e||!C.test("."+e))throw w("badmember",e);for(var e=e.split("."),n=0,g=e.length;n 2 | 3 | 5 | 6 |
7 |
8 |
9 |

10 | 11 | {{ product.artist }}: {{ product.title }} 12 |

13 |
14 |
15 |
16 |
17 |
18 |
Current price:
19 |
20 | {{ product.current_price.currency }} {{ product.current_price.value | number: 2 }} 21 | No current price available (as of {{ product.current_price.date_seen | date: 'MMMM d, y hh:mm a Z' }} - Details)
22 |
23 |
24 |
Highest price:
25 |
{{ product.highest_price.currency }} {{ product.highest_price.value | number: 2 }} (as of {{ product.highest_price.date_seen | date: 'MMMM d, y hh:mm a Z' }} - Details)
26 |
No highest price available.
27 |
28 |
29 |
Lowest price:
30 |
{{ product.lowest_price.currency }} {{ product.lowest_price.value | number: 2 }} (as of {{ product.lowest_price.date_seen | date: 'MMMM d, y hh:mm a Z' }} - Details)
31 |
No lowest price available.
32 |
33 |
34 |
Limit:
35 |
{{ product.current_price.currency }} {{ subscription.price_limit | number: 2 }} ({{ subscription.email_notification.email }})
36 |
37 | 40 |
41 |
42 |
43 |
44 |
45 | 46 | 47 |
48 |
49 | 50 |
51 |
52 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/angular/angular-responsive-images.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Angular responsive images 3 | * @version v0.0.0-dev-2013-06-19 4 | * @link https://github.com/c0bra/angular-res-img.git 5 | * @license MIT License, http://www.opensource.org/licenses/MIT 6 | */(function(){ 7 | 8 | var app = angular.module('ngResponsiveImages', []); 9 | 10 | // Default queries (stolen from Zurb Foundation) 11 | app.value('presetMediaQueries', { 12 | 'default': 'only screen and (min-width: 1px)', 13 | 'small': 'only screen and (min-width: 768px)', 14 | 'medium': 'only screen and (min-width: 1280px)', 15 | 'large': 'only screen and (min-width: 1440px)', 16 | 'landscape': 'only screen and (orientation: landscape)', 17 | 'portrait': 'only screen and (orientation: portrait)', 18 | 'retina': 'only screen and (-webkit-min-device-pixel-ratio: 2), ' + 19 | 'only screen and (min--moz-device-pixel-ratio: 2), ' + 20 | 'only screen and (-o-min-device-pixel-ratio: 2/1), ' + 21 | 'only screen and (min-device-pixel-ratio: 2), ' + 22 | 'only screen and (min-resolution: 192dpi), ' + 23 | 'only screen and (min-resolution: 2dppx)' 24 | }); 25 | 26 | app.directive('ngSrcResponsive', ['presetMediaQueries', '$timeout', function(presetMediaQueries, $timeout) { 27 | return { 28 | restrict: 'A', 29 | priority: 100, 30 | link: function(scope, elm, attrs) { 31 | // Double-check that the matchMedia function matchMedia exists 32 | if (typeof(matchMedia) !== 'function') { 33 | throw "Function 'matchMedia' does not exist"; 34 | } 35 | 36 | // Array of media query and listener sets 37 | // 38 | // { 39 | // mql: 40 | // listener: function () { ... } 41 | // } 42 | // 43 | var listenerSets = []; 44 | 45 | // Query that gets run on link, whenever the directive attr changes, and whenever 46 | var waiting = false; 47 | function updateFromQuery(querySets) { 48 | // Throttle calling this function so that multiple media query change handlers don't try to run concurrently 49 | if (!waiting) { 50 | $timeout(function() { 51 | // Destroy registered listeners, we will re-register them below 52 | angular.forEach(listenerSets, function(set) { 53 | set.mql.removeListener(set.listener); 54 | }); 55 | 56 | // Clear the deregistration functions 57 | listenerSets = []; 58 | var lastTrueQuerySet; 59 | 60 | // for (var query in querySets) { 61 | angular.forEach(querySets, function(set) { 62 | // if (querySets.hasOwnProperty(query)) { 63 | 64 | var queryText = set[0]; 65 | 66 | // If we were passed a preset query, use its value instead 67 | var query = queryText; 68 | if (presetMediaQueries.hasOwnProperty(queryText)) { 69 | query = presetMediaQueries[queryText]; 70 | } 71 | 72 | var mq = matchMedia(query); 73 | 74 | if (mq.matches) { 75 | lastTrueQuerySet = set; 76 | } 77 | 78 | // Listener function for this query 79 | var queryListener = function(mql) { 80 | // TODO: add throttling or a debounce here (or somewhere) to prevent this function from being called a ton of times 81 | updateFromQuery(querySets); 82 | }; 83 | 84 | // Add a listener for when this query's match changes 85 | mq.addListener(queryListener); 86 | 87 | listenerSets.push({ 88 | mql: mq, 89 | listener: queryListener 90 | }); 91 | }); 92 | 93 | if (lastTrueQuerySet) { 94 | setSrc( lastTrueQuerySet[1] ); 95 | } 96 | 97 | waiting = false; 98 | }, 0); 99 | 100 | waiting = true; 101 | } 102 | } 103 | 104 | 105 | function setSrc(src) { 106 | elm.attr('src', src); 107 | } 108 | 109 | var updaterDereg; 110 | attrs.$observe('ngSrcResponsive', function(value) { 111 | var querySets = scope.$eval(value); 112 | 113 | if (querySets instanceof Array === false) { 114 | throw "Expected evaluate ng-src-responsive to evaluate to an Array, instead got: " + querySets; 115 | } 116 | 117 | updateFromQuery(querySets); 118 | 119 | // Remove the previous matchMedia listener 120 | if (typeof(updaterDereg) === 'function') { updaterDereg(); } 121 | 122 | // Add a global match-media listener back 123 | // var mq = matchMedia('only screen and (min-width: 1px)'); 124 | // console.log('mq', mq); 125 | // updaterDereg = mq.addListener(function(){ 126 | // console.log('updating!'); 127 | // updateFromQuery(querySets); 128 | // }); 129 | }); 130 | } 131 | }; 132 | }]); 133 | 134 | })(); 135 | -------------------------------------------------------------------------------- /price_monitor/api/renderers/PriceChartPNGRenderer.py: -------------------------------------------------------------------------------- 1 | """Module for rendering price charts as PNG""" 2 | import dateutil.parser 3 | import hashlib 4 | 5 | from ... import app_settings 6 | 7 | from django.core.cache import caches 8 | from django.core.cache.backends.base import InvalidCacheBackendError 9 | 10 | from pygal import DateTimeLine 11 | from pygal.style import RedBlueStyle 12 | 13 | from rest_framework.renderers import BaseRenderer 14 | 15 | from tempfile import TemporaryFile 16 | 17 | 18 | def bool_helper(x): 19 | """ 20 | Returns True if the value is something that can be mapped to a boolean value. 21 | 22 | :param x: the value to check 23 | :return: the mapped boolean value or False if not mappable 24 | """ 25 | return x in [1, '1', 'true', 'True'] 26 | 27 | 28 | class PriceChartPNGRenderer(BaseRenderer): 29 | 30 | """A renderer to render charts as PNG for prices""" 31 | 32 | media_type = 'image/png' 33 | format = 'png' 34 | charset = None 35 | render_style = 'binary' 36 | 37 | # TODO: documentation 38 | allowed_chart_url_args = { 39 | 'height': lambda x: int(x), # pylint:disable=unnecessary-lambda 40 | 'width': lambda x: int(x), # pylint:disable=unnecessary-lambda 41 | 'margin': lambda x: int(x), # pylint:disable=unnecessary-lambda 42 | 'spacing': lambda x: int(x), # pylint:disable=unnecessary-lambda 43 | 'show_dots': bool_helper, 44 | 'show_legend': bool_helper, 45 | 'show_x_labels': bool_helper, 46 | 'show_y_labels': bool_helper, 47 | 'show_minor_y_labels': bool_helper, 48 | 'y_labels_major_count': lambda x: int(x), # pylint:disable=unnecessary-lambda 49 | } 50 | 51 | allowed_style_url_args = { 52 | 'no_data_font_size': lambda x: int(x), # pylint:disable=unnecessary-lambda 53 | } 54 | 55 | def render(self, data, accepted_media_type=None, renderer_context=None): # pylint:disable=unused-argument 56 | """Renders `data` into serialized XML.""" 57 | # first get the cache to use or None 58 | try: 59 | cache = caches[app_settings.PRICE_MONITOR_GRAPH_CACHE_NAME] 60 | except InvalidCacheBackendError: 61 | cache = None 62 | # sanitize arguments 63 | sanitized_args = self.sanitize_allowed_args(renderer_context['request']) if 'request' in renderer_context else {} 64 | 65 | # generate cache key 66 | cache_key = self.create_cache_key(data, sanitized_args) 67 | # only read from cache if there is any 68 | content = cache.get(cache_key) if cache is not None else None 69 | if content is None: 70 | # create graph instance 71 | graph = self.create_graph(data, sanitized_args) 72 | 73 | # write graph to temporary file 74 | with TemporaryFile() as file_: 75 | graph.render_to_png(file_) 76 | 77 | # only write to cache if there is any 78 | if cache is not None: 79 | # seek back to start 80 | file_.seek(0) 81 | cache.set(cache_key, file_.read()) 82 | # and back to start again 83 | file_.seek(0) 84 | # return the content 85 | return file_.read() 86 | else: 87 | # return the cache content 88 | return content 89 | 90 | def sanitize_allowed_args(self, request): 91 | """Checks url arguments by using the sanitation methods given in self.allowed_*_url_args""" 92 | sanitized_args = {} 93 | if request.method == 'POST': 94 | args = request.POST 95 | elif request.method == 'GET': 96 | args = request.GET 97 | else: 98 | return sanitized_args 99 | 100 | for arg, sanitizer in self.allowed_chart_url_args.items() ^ self.allowed_style_url_args.items(): 101 | if arg in args: 102 | try: 103 | sanitized_args[arg] = sanitizer(args[arg]) 104 | except ValueError: 105 | # sanitation gone wrong, so pass 106 | continue 107 | return sanitized_args 108 | 109 | def create_cache_key(self, data, args): 110 | """Creates a cache key based on rendering data""" 111 | hash_data = str(data).encode('utf-8') 112 | hash_data += str(args).encode('utf-8') 113 | return app_settings.PRICE_MONITOR_GRAPH_CACHE_KEY_PREFIX + hashlib.md5(hash_data).hexdigest() 114 | 115 | def create_graph(self, data, args): 116 | """Creates the graph based on rendering data""" 117 | style_arguments = {} 118 | for arg in self.allowed_style_url_args.keys(): 119 | if arg in args: 120 | style_arguments.update({arg: args[arg]}) 121 | 122 | line_chart_arguments = { 123 | 'style': RedBlueStyle(**style_arguments), 124 | 'x_label_rotation': 25, 125 | 'x_value_formatter': lambda dt: dt.strftime('%y-%m-%d %H:%M'), 126 | } 127 | for arg in self.allowed_chart_url_args.keys(): 128 | if arg in args: 129 | line_chart_arguments.update({arg: args[arg]}) 130 | 131 | line_chart = DateTimeLine(**line_chart_arguments) 132 | if data: 133 | values = [(dateutil.parser.parse(price['date_seen']), price['value']) for price in data] 134 | line_chart.add(data[0]['currency'], values) 135 | return line_chart 136 | -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/app/partials/product-list.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 7 | {{ product.title }} 9 | 10 | 11 |
12 |

13 | 14 | 15 | {{ product.artist }}: {{ product.title }} 16 | 17 |

18 |
19 |
20 |
21 |
Price:
22 |
23 | {{ product.current_price.currency }} {{ product.current_price.value | number: 2 }} 24 | No price available (as of {{ product.current_price.date_seen | date: 'MMMM d, y hh:mm a Z' }} - Details) 25 |
26 |
27 |
28 |
Limit:
29 |
{{ product.current_price.currency || product.highest_price.currency || product.lowest_price.currency }} {{ subscription.price_limit | number: 2 }}{{ $last ? '' : ', ' }}
30 |
31 |
32 |
33 | 34 | 35 | 36 |
37 |
38 |
39 |
40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 |
48 | 49 |
50 |
51 | 54 | 55 | 56 |
57 |
58 | 59 |
60 |
61 | 64 |
65 |
66 |
67 |
68 |
69 | 70 | 71 |
72 |
73 |
74 | 75 | -------------------------------------------------------------------------------- /price_monitor/app_settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.translation import ugettext_lazy 3 | 4 | 5 | # global AWS access settings 6 | PRICE_MONITOR_AWS_ACCESS_KEY_ID = getattr(settings, 'PRICE_MONITOR_AWS_ACCESS_KEY_ID', '') 7 | PRICE_MONITOR_AWS_SECRET_ACCESS_KEY = getattr(settings, 'PRICE_MONITOR_AWS_SECRET_ACCESS_KEY', '') 8 | PRICE_MONITOR_AMAZON_PRODUCT_API_REGION = getattr(settings, 'PRICE_MONITOR_AMAZON_PRODUCT_API_REGION', '') 9 | PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG = getattr(settings, 'PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG', '') 10 | PRICE_MONITOR_AMAZON_ASSOCIATE_NAME = getattr(settings, 'PRICE_MONITOR_AMAZON_ASSOCIATE_NAME', '') 11 | 12 | # project settings 13 | PRICE_MONITOR_BASE_URL = getattr(settings, 'PRICE_MONITOR_BASE_URL', 'http://localhost:8000') 14 | 15 | # Amazon Disclaimers 16 | # Disclaimer for Product Advertising API, see https://partnernet.amazon.de/gp/advertising/api/detail/agreement.html and #12 17 | PRICE_MONITOR_AMAZON_PRODUCT_ADVERTISING_API_DISCLAIMER = 'CERTAIN CONTENT THAT APPEARS ON THIS SITE COMES FROM AMAZON EU S.à r.l. THIS CONTENT IS ' \ 18 | 'PROVIDED \'AS IS\' AND IS SUBJECT TO CHANGE OR REMOVAL AT ANY TIME.' 19 | # Disclaimer for Amazon associates, see https://partnernet.amazon.de/gp/associates/agreement (10) and #77 20 | # available sites for disclaimer text, just as reference, unused in code 21 | PRICE_MONITOR_AMAZON_ASSOCIATE_SITES = [ 22 | 'Amazon.co.uk', 23 | 'Local.Amazon.co.uk', 24 | 'Amazon.de', 25 | 'de.BuyVIP.com', 26 | 'Amazon.fr', 27 | 'Amazon.it', 28 | 'it.BuyVIP.com', 29 | 'Amazon.es', 30 | 'es.BuyVIP.com', 31 | ] 32 | PRICE_MONITOR_AMAZON_ASSOCIATE_SITE = getattr(settings, 'PRICE_MONITOR_AMAZON_ASSOCIATE_SITE', '') 33 | # Disclaimer for Amazon associates, see https://partnernet.amazon.de/gp/associates/agreement (10) and #77 34 | PRICE_MONITOR_ASSOCIATE_DISCLAIMER = '{name} is a participant in the Amazon EU Associates Programme, an affiliate advertising programme designed to provide' \ 35 | ' a means for sites to earn advertising fees by advertising and linking to {amazon_site_name}.'.format( 36 | name=PRICE_MONITOR_AMAZON_ASSOCIATE_NAME, 37 | amazon_site_name=PRICE_MONITOR_AMAZON_ASSOCIATE_SITE, 38 | ) 39 | 40 | # server infrastructural settings 41 | # serve the product images via HTTPS 42 | PRICE_MONITOR_IMAGES_USE_SSL = getattr(settings, 'PRICE_MONITOR_IMAGES_USE_SSL', True) 43 | # HTTPS host to use for getting the images. Seems to be https://images-.ssl-images-amazon.com. 44 | PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN = getattr(settings, 'PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN', 'https://images-eu.ssl-images-amazon.com') 45 | 46 | # synchronization settings 47 | # refresh product after 12 hours 48 | PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES = int(getattr(settings, 'PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES', 12 * 60)) 49 | 50 | # notification settings 51 | # time after when to notify about a subscription again 52 | PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES = int(getattr(settings, 'PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES', 60 * 24 * 7)) 53 | # the email sender for notification emails 54 | PRICE_MONITOR_EMAIL_SENDER = getattr(settings, 'PRICE_MONITOR_EMAIL_SENDER', 'noreply@localhost') 55 | # default currency 56 | PRICE_MONITOR_DEFAULT_CURRENCY = getattr(settings, 'PRICE_MONITOR_DEFAULT_CURRENCY', 'EUR') 57 | # i18n for email notifications 58 | PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_SUBJECT = getattr( 59 | settings, 60 | 'PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_SUBJECT', 61 | ugettext_lazy('Price limit for %(product)s reached') 62 | ) 63 | PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_BODY = getattr( 64 | settings, 65 | 'PRICE_MONITOR_I18N_EMAIL_NOTIFICATION_BODY', 66 | ugettext_lazy( 67 | 'The price limit of {price_limit:0.2f} {currency:s} has been reached for the article "{product_title:s}"\n' 68 | 'Current price is {price:0.2f} {currency:s} ({price_date:s}).' 69 | '\n\n' 70 | 'Please support our platform by using this affiliate link for buying the product: {url_product_amazon:s}' 71 | '\n' 72 | 'Adjust the price limits for the products here: {url_product_detail:s}' 73 | '\n\n' 74 | '{additional_text:s}' 75 | '\n' 76 | 'Regards,' 77 | '\n' 78 | 'The Team' 79 | ) 80 | ) 81 | PRICE_MONITOR_SITENAME = getattr(settings, 'PRICE_MONITOR_SITENAME', 'Price Monitor') 82 | 83 | # cache settings 84 | # key of cache (according to project config) to use for graphs. Set to none to disable caching 85 | PRICE_MONITOR_GRAPH_CACHE_NAME = getattr(settings, 'PRICE_MONITOR_GRAPH_CACHE_NAME', None) 86 | # prefix for cache key used for graphs 87 | PRICE_MONITOR_GRAPH_CACHE_KEY_PREFIX = getattr(settings, 'PRICE_MONITOR_GRAPH_CACHE_KEY_PREFIX', 'graph_') 88 | 89 | 90 | # internal settings - not to be overwritten by user 91 | # Regex for ASIN validation 92 | PRICE_MONITOR_ASIN_REGEX = r'[A-Z0-9\-]+' 93 | # Product Advertising API relevant settings 94 | # 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. 95 | PRICE_MONITOR_PA_RESPONSE_GROUP = 'Large' 96 | # mapping of PRICE_MONITOR_AMAZON_PRODUCT_API_REGION to the appropriate amazon domain ending 97 | PRICE_MONITOR_AMAZON_REGION_DOMAINS = { 98 | 'CA': 'ca', 99 | 'DE': 'de', 100 | 'ES': 'es', 101 | 'FR': 'fr', 102 | 'IN': 'in', 103 | 'IT': 'it', 104 | 'JP': 'co.jp', 105 | 'UK': 'co.uk', 106 | 'US': 'com', 107 | } 108 | PRICE_MONITOR_OFFER_URL = 'http://www.amazon.{domain:s}/dp/{asin:s}/?tag={assoc_tag:s}' 109 | -------------------------------------------------------------------------------- /price_monitor/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='EmailNotification', 17 | fields=[ 18 | ('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)), 19 | ('public_id', models.CharField(db_index=True, unique=True, editable=False, verbose_name='Public-ID', max_length=36)), 20 | ('email', models.EmailField(verbose_name='Email address', max_length=254)), 21 | ('owner', models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name='Owner')), 22 | ], 23 | options={ 24 | 'ordering': ('email',), 25 | 'verbose_name': 'Email Notification', 26 | 'verbose_name_plural': 'Email Notifications', 27 | }, 28 | bases=(models.Model,), 29 | ), 30 | migrations.CreateModel( 31 | name='Price', 32 | fields=[ 33 | ('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)), 34 | ('value', models.FloatField(verbose_name='Price')), 35 | ('currency', models.CharField(verbose_name='Currency', max_length=3)), 36 | ('date_seen', models.DateTimeField(verbose_name='Date of price')), 37 | ], 38 | options={ 39 | 'ordering': ('date_seen',), 40 | 'get_latest_by': 'date_seen', 41 | 'verbose_name': 'Price', 42 | 'verbose_name_plural': 'Prices', 43 | }, 44 | bases=(models.Model,), 45 | ), 46 | migrations.CreateModel( 47 | name='Product', 48 | fields=[ 49 | ('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)), 50 | ('date_creation', models.DateTimeField(verbose_name='Date of creation', auto_now_add=True)), 51 | ('date_updated', models.DateTimeField(verbose_name='Date of last update', auto_now=True)), 52 | ('date_last_synced', models.DateTimeField(null=True, verbose_name='Date of last synchronization', blank=True)), 53 | ('status', models.SmallIntegerField(verbose_name='Status', choices=[(0, 'Created'), (1, 'Synced over API'), (2, 'Unsynchable')], default=0)), 54 | ('asin', models.CharField(unique=True, verbose_name='ASIN', max_length=100)), 55 | ('title', models.CharField(null=True, verbose_name='Title', blank=True, max_length=255)), 56 | ('isbn', models.CharField(null=True, verbose_name='ISBN', blank=True, max_length=10)), 57 | ('eisbn', models.CharField(null=True, verbose_name='E-ISBN', blank=True, max_length=13)), 58 | ('binding', models.CharField(null=True, verbose_name='Binding', blank=True, max_length=255)), 59 | ('date_publication', models.DateField(null=True, verbose_name='Publication date', blank=True)), 60 | ('date_release', models.DateField(null=True, verbose_name='Release date', blank=True)), 61 | ('audience_rating', models.CharField(null=True, verbose_name='Audience rating', blank=True, max_length=255)), 62 | ('large_image_url', models.URLField(null=True, verbose_name='URL to large product image', blank=True)), 63 | ('medium_image_url', models.URLField(null=True, verbose_name='URL to medium product image', blank=True)), 64 | ('small_image_url', models.URLField(null=True, verbose_name='URL to small product image', blank=True)), 65 | ('offer_url', models.URLField(null=True, verbose_name='URL to the offer', blank=True)), 66 | ], 67 | options={ 68 | 'ordering': ('title', 'asin'), 69 | 'verbose_name': 'Product', 70 | 'verbose_name_plural': 'Products', 71 | }, 72 | bases=(models.Model,), 73 | ), 74 | migrations.CreateModel( 75 | name='Subscription', 76 | fields=[ 77 | ('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)), 78 | ('public_id', models.CharField(db_index=True, unique=True, editable=False, verbose_name='Public-ID', max_length=36)), 79 | ('price_limit', models.FloatField(verbose_name='Price limit')), 80 | ('date_last_notification', models.DateTimeField(null=True, verbose_name='Date of last sent notification', blank=True)), 81 | ('email_notification', models.ForeignKey(to='price_monitor.EmailNotification', verbose_name='Email Notification')), 82 | ('owner', models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name='Owner')), 83 | ('product', models.ForeignKey(to='price_monitor.Product', verbose_name='Product')), 84 | ], 85 | options={ 86 | 'ordering': ('product__title', 'price_limit', 'email_notification__email'), 87 | 'verbose_name': 'Subscription', 88 | 'verbose_name_plural': 'Subscriptions', 89 | }, 90 | bases=(models.Model,), 91 | ), 92 | migrations.AddField( 93 | model_name='product', 94 | name='subscribers', 95 | field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, verbose_name='Subscribers', through='price_monitor.Subscription'), 96 | preserve_default=True, 97 | ), 98 | migrations.AddField( 99 | model_name='price', 100 | name='product', 101 | field=models.ForeignKey(to='price_monitor.Product', verbose_name='Product'), 102 | preserve_default=True, 103 | ), 104 | ] 105 | -------------------------------------------------------------------------------- /price_monitor/templates/price_monitor/angular_index_view.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | Amazon Price Monitor 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 34 | 35 |
36 |
37 |
38 | 44 | {% block footer %}{% endblock %} 45 |
46 |
47 | 48 | 49 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /price_monitor/models/Product.py: -------------------------------------------------------------------------------- 1 | """Model for an Amazon product""" 2 | from django.conf import settings 3 | from django.db import models 4 | from django.utils import formats 5 | from django.utils.translation import ( 6 | ugettext as _, 7 | ugettext_lazy, 8 | ) 9 | 10 | from price_monitor import app_settings 11 | from price_monitor.models.Price import Price 12 | 13 | from urllib.parse import ( 14 | urljoin, 15 | urlparse, 16 | ) 17 | 18 | 19 | class Product(models.Model): 20 | 21 | """Product to be monitored.""" 22 | 23 | STATUS_CHOICES = ( 24 | (0, _('Created'),), 25 | (1, _('Synced over API'),), 26 | (2, _('Unsynchable'),), 27 | ) 28 | 29 | # date values 30 | date_creation = models.DateTimeField(auto_now_add=True, verbose_name=_('Date of creation')) 31 | date_updated = models.DateTimeField(auto_now=True, verbose_name=_('Date of last update')) 32 | date_last_synced = models.DateTimeField(blank=True, null=True, verbose_name=_('Date of last synchronization')) 33 | 34 | # synchronization status 35 | status = models.SmallIntegerField(choices=STATUS_CHOICES, default=0, verbose_name=_('Status')) 36 | 37 | # relations 38 | subscribers = models.ManyToManyField(settings.AUTH_USER_MODEL, through='Subscription', verbose_name=_('Subscribers')) 39 | 40 | # amazon specific fields 41 | asin = models.CharField(max_length=100, unique=True, verbose_name=_('ASIN')) 42 | title = models.CharField(blank=True, null=True, max_length=255, verbose_name=_('Title')) 43 | artist = models.CharField(blank=True, null=True, max_length=255, verbose_name=_('Artist')) 44 | isbn = models.CharField(blank=True, null=True, max_length=10, verbose_name=_('ISBN')) 45 | eisbn = models.CharField(blank=True, null=True, max_length=13, verbose_name=_('E-ISBN')) 46 | binding = models.CharField(blank=True, null=True, max_length=255, verbose_name=_('Binding')) 47 | date_publication = models.DateField(blank=True, null=True, verbose_name=_('Publication date')) 48 | date_release = models.DateField(blank=True, null=True, verbose_name=_('Release date')) 49 | audience_rating = models.CharField(blank=True, null=True, max_length=255, verbose_name=_('Audience rating')) 50 | large_image_url = models.URLField(blank=True, null=True, verbose_name=_('URL to large product image')) 51 | medium_image_url = models.URLField(blank=True, null=True, verbose_name=_('URL to medium product image')) 52 | small_image_url = models.URLField(blank=True, null=True, verbose_name=_('URL to small product image')) 53 | offer_url = models.URLField(blank=True, null=True, verbose_name=_('URL to the offer')) 54 | 55 | current_price = models.ForeignKey(Price, on_delete=models.CASCADE, blank=True, null=True, related_name='product_current', verbose_name=_('Current price')) 56 | highest_price = models.ForeignKey(Price, on_delete=models.CASCADE, blank=True, null=True, related_name='product_highest', 57 | verbose_name=_('Highest price ever')) 58 | lowest_price = models.ForeignKey(Price, on_delete=models.CASCADE, blank=True, null=True, related_name='product_lowest', verbose_name=_('Lowest price ever')) 59 | 60 | def get_prices_for_chart(self): 61 | """ 62 | Returns all prices of the product. 63 | 64 | :return: list 65 | """ 66 | # TODO: be able to specify a range, like last 100 days 67 | # TODO: don't select all prices, but a representative representation, like each 5th price aso 68 | 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')] 69 | 70 | def set_failed_to_sync(self): 71 | """Marks the product as failed to sync. This happens if the Amazon API request for this product fails.""" 72 | self.status = 2 73 | self.save() 74 | 75 | def get_image_urls(self): 76 | """ 77 | Returns all image urls as dictionary. The size is the key. 78 | 79 | Respects HTTP/HTTPS configuration. 80 | :return: image dict 81 | :rtype: dict 82 | """ 83 | return { 84 | 'small': self.__get_image_url(self.small_image_url), 85 | 'medium': self.__get_image_url(self.medium_image_url), 86 | 'large': self.__get_image_url(self.large_image_url), 87 | } 88 | 89 | def __get_image_url(self, url): 90 | """ 91 | Returns the correct image url depending on the settings. Will either be a HTTP or HTTPS host. 92 | 93 | :param url: the original (HTTP) image url 94 | :return: the adjusted image url if SSL is enabled 95 | """ 96 | if app_settings.PRICE_MONITOR_IMAGES_USE_SSL: 97 | return urljoin(app_settings.PRICE_MONITOR_AMAZON_SSL_IMAGE_DOMAIN, urlparse(url).path) 98 | 99 | return url 100 | 101 | def get_graph_cache_key(self): 102 | """ 103 | Returns cache key used for caching the price graph 104 | 105 | :return: the cache key 106 | :rtype: str 107 | """ 108 | return 'graph-{0!s}-{1!s}'.format(self.asin, self.date_last_synced.isoformat() if self.date_last_synced is not None else '') 109 | 110 | def get_title(self): 111 | """ 112 | Returns the title of the product. 113 | 114 | :return: the title 115 | :rtype: str 116 | """ 117 | return '{0}{1}'.format( 118 | '{0}: '.format(self.artist) if self.artist is not None and len(self.artist) > 0 else '', 119 | self.title if self.title is not None and len(self.title) > 0 else _('Unsynchronized Product'), 120 | ) 121 | 122 | def get_detail_url(self): 123 | """ 124 | Returns the url to a product detail view. 125 | 126 | As the frontend is AngularJS, we cannot use any Django reverse functionality. 127 | :return: the link 128 | """ 129 | return '{base_url:s}/#/products/{asin:s}'.format( 130 | base_url=app_settings.PRICE_MONITOR_BASE_URL, 131 | asin=self.asin, 132 | ) 133 | 134 | def __str__(self): 135 | """ 136 | 137 | Returns the unicode representation of the Product. 138 | :return: the unicode representation 139 | :rtype: unicode 140 | """ 141 | return '{0} (ASIN: {1})'.format(self.get_title(), self.asin) 142 | 143 | class Meta(object): 144 | 145 | """Django meta config""" 146 | 147 | app_label = 'price_monitor' 148 | verbose_name = ugettext_lazy('Product') 149 | verbose_name_plural = ugettext_lazy('Products') 150 | ordering = ('title', 'asin',) 151 | -------------------------------------------------------------------------------- /price_monitor/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: 1\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2015-10-28 19:33+0100\n" 11 | "PO-Revision-Date: 2015-10-28 19:42+0100\n" 12 | "Last-Translator: Alexander Herrmann \n" 13 | "Language-Team: Deutsch \n" 14 | "Language: Deutsch\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "X-Generator: Poedit 1.5.4\n" 20 | "X-Poedit-SourceCharset: UTF-8\n" 21 | 22 | #: admin.py:27 23 | msgid "Reset to status \"Created\"." 24 | msgstr "Auf Status \"Erstellt\" zurücksetzen." 25 | 26 | #: admin.py:38 27 | msgid "Resynchronize with API" 28 | msgstr "Mit API resynchronisieren" 29 | 30 | #: app_settings.py:32 31 | #, python-format 32 | msgid "Price limit for %(product)s reached" 33 | msgstr "Preislimit für %(product)s erreicht" 34 | 35 | #: app_settings.py:38 36 | #, python-format 37 | msgid "" 38 | "The price limit of %(price_limit)0.2f %(currency)s has been reached for the " 39 | "article \"%(product_title)s\" - the current price is %(price)0.2f " 40 | "%(currency)s.\n" 41 | "\n" 42 | "Please support our platform by using this link for buying: %(link)s\n" 43 | "\n" 44 | "\n" 45 | "Regards,\n" 46 | "The Team" 47 | msgstr "" 48 | "Das Preislimit von %(price_limit)0.2f %(currency)s wurde für den Artikel " 49 | "\"%(product_title)s\" erreicht - der aktuelle Preis ist %(price)0.2f " 50 | "%(currency)s.\n" 51 | "\n" 52 | "Bitte unterstütze unsere Plattform, indem du den Artikel über diesen Link " 53 | "einkaufst: %(link)s\n" 54 | "\n" 55 | "\n" 56 | "Grüße,\n" 57 | "Das Team" 58 | 59 | #: forms.py:14 models/Product.py:40 60 | msgid "ASIN" 61 | msgstr "ASIN" 62 | 63 | #: models/EmailNotification.py:14 models/Subscription.py:12 64 | msgid "Owner" 65 | msgstr "Besitzer" 66 | 67 | #: models/EmailNotification.py:15 68 | msgid "Email address" 69 | msgstr "E-Mail-Adresse" 70 | 71 | #: models/EmailNotification.py:31 models/Subscription.py:16 72 | msgid "Email Notification" 73 | msgstr "E-Mail-Benachrichtigung" 74 | 75 | #: models/EmailNotification.py:32 76 | msgid "Email Notifications" 77 | msgstr "E-Mail-Benachrichtigungen" 78 | 79 | #: models/Price.py:9 models/Price.py:29 80 | msgid "Price" 81 | msgstr "Preis" 82 | 83 | #: models/Price.py:10 84 | msgid "Currency" 85 | msgstr "Währung" 86 | 87 | #: models/Price.py:11 88 | msgid "Date of price" 89 | msgstr "Datum des Preises" 90 | 91 | #: models/Price.py:12 models/Product.py:127 models/Subscription.py:13 92 | msgid "Product" 93 | msgstr "Produkt" 94 | 95 | #: models/Price.py:30 96 | msgid "Prices" 97 | msgstr "Preise" 98 | 99 | #: models/Product.py:23 100 | msgid "Created" 101 | msgstr "Erstellt" 102 | 103 | #: models/Product.py:24 104 | msgid "Synced over API" 105 | msgstr "über API synchronisiert" 106 | 107 | #: models/Product.py:25 108 | msgid "Unsynchable" 109 | msgstr "nicht synchronisierbar" 110 | 111 | #: models/Product.py:29 112 | msgid "Date of creation" 113 | msgstr "Erstellungsdatum" 114 | 115 | #: models/Product.py:30 116 | msgid "Date of last update" 117 | msgstr "Datum der letzten Aktualisierung" 118 | 119 | #: models/Product.py:31 120 | msgid "Date of last synchronization" 121 | msgstr "Datum der letzten Synchronisierung" 122 | 123 | #: models/Product.py:34 124 | msgid "Status" 125 | msgstr "Status" 126 | 127 | #: models/Product.py:37 128 | msgid "Subscribers" 129 | msgstr "Abonnenten" 130 | 131 | #: models/Product.py:41 132 | msgid "Title" 133 | msgstr "Titel" 134 | 135 | #: models/Product.py:42 136 | msgid "Artist" 137 | msgstr "Künstler" 138 | 139 | #: models/Product.py:43 140 | msgid "ISBN" 141 | msgstr "ISBN" 142 | 143 | #: models/Product.py:44 144 | msgid "E-ISBN" 145 | msgstr "E-ISBN" 146 | 147 | #: models/Product.py:45 148 | msgid "Binding" 149 | msgstr "Bindung" 150 | 151 | #: models/Product.py:46 152 | msgid "Publication date" 153 | msgstr "Erscheinungsdatum" 154 | 155 | #: models/Product.py:47 156 | msgid "Release date" 157 | msgstr "Freigabedatum" 158 | 159 | #: models/Product.py:48 160 | msgid "Audience rating" 161 | msgstr "Zielgruppe" 162 | 163 | #: models/Product.py:49 164 | msgid "URL to large product image" 165 | msgstr "URL zum großen Produktbild" 166 | 167 | #: models/Product.py:50 168 | msgid "URL to medium product image" 169 | msgstr "URL zum mittleren Produktbild" 170 | 171 | #: models/Product.py:51 172 | msgid "URL to small product image" 173 | msgstr "URL zum kleinen Produktbild" 174 | 175 | #: models/Product.py:52 176 | msgid "URL to the offer" 177 | msgstr "URL zum Angebot" 178 | 179 | #: models/Product.py:54 180 | #| msgid "Currency" 181 | msgid "Current price" 182 | msgstr "Aktueller Preis" 183 | 184 | #: models/Product.py:55 185 | msgid "Highest price ever" 186 | msgstr "Höchster Preis" 187 | 188 | #: models/Product.py:56 189 | msgid "Lowest price ever" 190 | msgstr "Niedrigster Preis" 191 | 192 | #: models/Product.py:114 193 | msgid "Unsynchronized Product" 194 | msgstr "Nicht synchronisiertes Produkt" 195 | 196 | #: models/Product.py:128 197 | msgid "Products" 198 | msgstr "Produkte" 199 | 200 | #: models/Subscription.py:14 201 | msgid "Price limit" 202 | msgstr "Preislimit" 203 | 204 | #: models/Subscription.py:15 205 | msgid "Date of last sent notification" 206 | msgstr "Datum der zuletzt gesendeten Benachrichtigung" 207 | 208 | #: models/Subscription.py:24 209 | msgid "Notification email" 210 | msgstr "Benachrichtigungs-E-Mail" 211 | 212 | #: models/Subscription.py:39 213 | msgid "Subscription" 214 | msgstr "Abonnement" 215 | 216 | #: models/Subscription.py:40 217 | msgid "Subscriptions" 218 | msgstr "Abonnements" 219 | 220 | #: models/mixins/PublicIDMixin.py:17 221 | msgid "Public-ID" 222 | msgstr "Public-ID" 223 | 224 | #~ msgid "Email notifications" 225 | #~ msgstr "E-Mail-Benachrichtigungen" 226 | 227 | #~ msgid "Add new email notifications" 228 | #~ msgstr "Neue E-Mail-Benachrichtigungen hinzufügen" 229 | 230 | #~ msgid "Already monitored products" 231 | #~ msgstr "Bereits überwachte Produkte" 232 | 233 | #~ msgid "" 234 | #~ "Product prices and availability are accurate as of the date/time " 235 | #~ "indicated and are subject to change. Any price and availability " 236 | #~ "information displayed on %(site_name)s at the time of purchase will apply " 237 | #~ "to the purchase of this product." 238 | #~ msgstr "" 239 | #~ "Produktpreise und -verfügbarkeit gelten zur angezeigten Zeit und können " 240 | #~ "sich ändern. Jede Preis- und Verügbarkeitsinformation, die auf " 241 | #~ "%(site_name)s zum Zeitpunkt der Bestellung angezeigt werden, treffen auch " 242 | #~ "für den Kauf des Produktes zu." 243 | 244 | #~ msgid "Details" 245 | #~ msgstr "Details" 246 | 247 | #~ msgid "No price information available." 248 | #~ msgstr "Keine Preisinformationen verfügbar." 249 | 250 | #~ msgid "Limit" 251 | #~ msgstr "Limit" 252 | 253 | #~ msgid "Remove" 254 | #~ msgstr "Entfernen" 255 | 256 | #~ msgid "Add new products" 257 | #~ msgstr "Neue Produkte hinzufügen" 258 | 259 | #~ msgid "Monitor ASINs" 260 | #~ msgstr "ASINs überwachen" 261 | 262 | #~ msgid "No price data available." 263 | #~ msgstr "Keine Preisdaten verfügbar." 264 | 265 | #~ msgid "at" 266 | #~ msgstr "um" 267 | 268 | #~ msgid "Please specify a list of ASINs as only argument, separated by comma!" 269 | #~ msgstr "" 270 | #~ "Bitte gib eine Liste von kommaseparierten ASINs als einziges Argument an!" 271 | 272 | #~ msgid "Please specify a single ASIN as only argument!" 273 | #~ msgstr "Bitte gib eine einzige ASIN als einziges Argument an!" 274 | -------------------------------------------------------------------------------- /price_monitor/product_advertising_api/api.py: -------------------------------------------------------------------------------- 1 | import bottlenose 2 | import logging 3 | import random 4 | import time 5 | 6 | from bs4 import BeautifulSoup 7 | 8 | from dateutil import parser 9 | 10 | from price_monitor import ( 11 | app_settings, 12 | utils, 13 | ) 14 | 15 | from urllib.error import HTTPError 16 | 17 | 18 | logger = logging.getLogger('price_monitor.product_advertising_api') 19 | 20 | 21 | class ProductAdvertisingAPI(object): 22 | """ 23 | A wrapper class for the necessary Amazon Product Advertising API calls. 24 | See the API reference here: http://docs.aws.amazon.com/AWSECommerceService/latest/DG/CHAP_ApiReference.html 25 | See bottlenose here: https://github.com/lionheart/bottlenose 26 | """ 27 | 28 | def __init__(self): 29 | self.__amazon = bottlenose.Amazon( 30 | AWSAccessKeyId=app_settings.PRICE_MONITOR_AWS_ACCESS_KEY_ID, 31 | AWSSecretAccessKey=app_settings.PRICE_MONITOR_AWS_SECRET_ACCESS_KEY, 32 | AssociateTag=app_settings.PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG, 33 | Region=app_settings.PRICE_MONITOR_AMAZON_PRODUCT_API_REGION, 34 | Parser=lambda response_text: BeautifulSoup(response_text, 'lxml'), 35 | ErrorHandler=ProductAdvertisingAPI.handle_error, 36 | ) 37 | 38 | @staticmethod 39 | def __get_item_attribute(item, attribute): 40 | """ 41 | Returns the attribute value from a bs4 parsed item. 42 | :param item: bs4 item returned from PA API upon item lookup 43 | :param attribute: the attribute to search for 44 | :return: the value if found, else None 45 | :rtype: basestring 46 | """ 47 | value = item.itemattributes.find_all(attribute, recursive=False) 48 | return value[0].string if len(value) == 1 else None 49 | 50 | @staticmethod 51 | def format_datetime(value): 52 | """ 53 | Formats the given value if it is not None in the given format. 54 | :param value: the value to format 55 | :type value: basestring 56 | :return: formatted datetime 57 | :rtype: basestring 58 | """ 59 | if value is not None: 60 | try: 61 | return parser.parse(value) 62 | except ValueError: 63 | logger.error('Unable to parse %s to a datetime', value) 64 | return None 65 | 66 | @staticmethod 67 | def handle_error(error): 68 | """ 69 | Generic error handler for bottlenose requests. 70 | @see https://github.com/lionheart/bottlenose#error-handling 71 | :param error: error information 72 | :type error: dict 73 | :return: if to retry the request 74 | :rtype: bool 75 | : 76 | """ 77 | ex = error['exception'] 78 | 79 | logger.error('Error upon requesting Amazon URL %s (Code: %s, Cache-URL: %s): %r', error['api_url'], error['cache_url'], ex, ex.code) 80 | 81 | # try reconnect 82 | if isinstance(ex, HTTPError) and ex.code == 503: 83 | time.sleep(random.expovariate(0.1)) 84 | return True 85 | 86 | return False 87 | 88 | def lookup_at_amazon(self, item_ids): 89 | """ 90 | Outsourced this call to better mock in tests. 91 | :param item_ids: the item ids 92 | :type item_ids: list 93 | :return: parsed xml 94 | :rtype: bs4.BeautifulSoup 95 | """ 96 | return self.__amazon.ItemLookup(ItemId=','.join(item_ids), ResponseGroup=app_settings.PRICE_MONITOR_PA_RESPONSE_GROUP) 97 | 98 | def item_lookup(self, item_ids): 99 | """ 100 | Lookup of the item with the given id on Amazon. Returns it values or None if something went wrong. 101 | :param item_ids: the item ids 102 | :type item_ids: list 103 | :return: the values of the item 104 | :rtype: dict 105 | """ 106 | logger.info('starting lookup for ASINs %s', ', '.join(item_ids)) 107 | item_response = self.lookup_at_amazon(item_ids) 108 | 109 | if getattr(item_response, 'items') is None: 110 | logger.error( 111 | 'Request for item lookup (ResponseGroup: %s, ASINs: %s) returned nothing', 112 | app_settings.PRICE_MONITOR_PA_RESPONSE_GROUP, 113 | ', '.join(item_ids), 114 | ) 115 | return dict() 116 | 117 | if item_response.items.request.isvalid.string == 'True': 118 | 119 | # the dict that will contain a key for every ASIN and as value the parsed values 120 | product_values = dict() 121 | 122 | for item_node in item_response.find_all(['item']): 123 | 124 | # parse the values 125 | try: 126 | isbn = self.__get_item_attribute(item_node, 'isbn') 127 | eisbn = self.__get_item_attribute(item_node, 'eisbn') 128 | if eisbn is None and isbn is not None: 129 | if len(isbn) == 13: 130 | eisbn = isbn 131 | isbn = None 132 | 133 | item_values = { 134 | 'asin': item_node.asin.string, 135 | 'title': item_node.itemattributes.title.string, 136 | 'artist': item_node.itemattributes.artist.string if item_node.itemattributes.artist is not None else None, 137 | 'isbn': isbn, 138 | 'eisbn': eisbn, 139 | 'binding': item_node.itemattributes.binding.string, 140 | 'date_publication': self.format_datetime(self.__get_item_attribute(item_node, 'publicationdate')), 141 | 'date_release': self.format_datetime(self.__get_item_attribute(item_node, 'releasedate')), 142 | 'large_image_url': item_node.largeimage.url.string if item_node.largeimage.url is not None else None, 143 | 'medium_image_url': item_node.mediumimage.url.string if item_node.mediumimage.url is not None else None, 144 | 'small_image_url': item_node.smallimage.url.string if item_node.smallimage.url is not None else None, 145 | 'offer_url': utils.get_offer_url(item_node.asin.string), 146 | 'audience_rating': self.__get_item_attribute(item_node, 'audiencerating'), 147 | } 148 | 149 | # check if there are offers, if so add price 150 | if item_node.offers is not None and int(item_node.offers.totaloffers.string) > 0: 151 | item_values['price'] = float(int(item_node.offers.offer.offerlisting.price.amount.string) / 100) 152 | item_values['currency'] = item_node.offers.offer.offerlisting.price.currencycode.string 153 | 154 | # insert into main dict 155 | product_values[item_values['asin']] = item_values 156 | except AttributeError: 157 | raise 158 | logger.error('fetching item values from returned XML for ASIN %s failed', item_node.asin) 159 | 160 | # check if all ASINs are included, if not write error message to log 161 | failed_asins = [] 162 | for asin in item_ids: 163 | if asin not in product_values.keys(): 164 | failed_asins.append(asin) 165 | 166 | if failed_asins: 167 | logger.error('Lookup for the following ASINs failed: %s', ', '.join(failed_asins)) 168 | 169 | # if there is at least a single ASIN in the list, return the list, else None 170 | return dict() if len(product_values) == 0 else product_values 171 | 172 | else: 173 | logger.error( 174 | 'Request for item lookup (ResponseGroup: %s, ASINs: %s) was not valid', 175 | app_settings.PRICE_MONITOR_PA_RESPONSE_GROUP, 176 | ', '.join(item_ids), 177 | ) 178 | return dict() 179 | -------------------------------------------------------------------------------- /price_monitor/api/serializers/ProductSerializer.py: -------------------------------------------------------------------------------- 1 | """Serializer for Product model""" 2 | from .SubscriptionSerializer import SubscriptionSerializer 3 | from ...models import EmailNotification, Product, Subscription 4 | 5 | from django.db import transaction 6 | 7 | from rest_framework import serializers 8 | 9 | 10 | class ProductSerializer(serializers.ModelSerializer): 11 | 12 | """ 13 | Product serializer. Serializes all fields needed for frontend and id from asin. 14 | Also sets all fields but asin to read only 15 | """ 16 | 17 | asin = serializers.CharField(max_length=100) 18 | 19 | # for these three values get_{{ value name }} is the default, but DRF prohibits setting the default value ... 20 | current_price = serializers.SerializerMethodField() 21 | highest_price = serializers.SerializerMethodField() 22 | lowest_price = serializers.SerializerMethodField() 23 | image_urls = serializers.SerializerMethodField() 24 | subscription_set = SubscriptionSerializer(many=True) 25 | 26 | def __render_price_dict(self, price): 27 | """ 28 | Renders price instance as dict 29 | 30 | :param price: price instance 31 | :type price: Price 32 | :return: price instance as dict 33 | :rtype: dict 34 | """ 35 | return { 36 | 'value': price.value, 37 | 'currency': price.currency, 38 | 'date_seen': price.date_seen, 39 | } 40 | 41 | def get_current_price(self, obj): 42 | """ 43 | Renderes current price dict as read only value into product representation 44 | 45 | :param obj: product to get price for 46 | :type obj: Product 47 | :returns: Dict with current price values 48 | :rtype: dict 49 | """ 50 | if obj.current_price: 51 | return self.__render_price_dict(obj.current_price) 52 | 53 | def get_highest_price(self, obj): 54 | """ 55 | Renders highest price dict as read only value into product representation 56 | 57 | :param obj: product to get price for 58 | :type obj: Product 59 | :returns: Dict with highest price values 60 | :rtype: dict 61 | """ 62 | if obj.highest_price: 63 | return self.__render_price_dict(obj.highest_price) 64 | 65 | def get_lowest_price(self, obj): 66 | """ 67 | Renders lowest price dict as read only value into product representation 68 | 69 | :param obj: product to get price for 70 | :type obj: Product 71 | :returns: Dict with lowest price values 72 | :rtype: dict 73 | """ 74 | if obj.lowest_price: 75 | return self.__render_price_dict(obj.lowest_price) 76 | 77 | def get_image_urls(self, obj): 78 | """ 79 | Renders image urls as read only value into product representation 80 | 81 | :param obj: object to get image urls for 82 | :type obj: Product 83 | :returns: dict with image urls 84 | :rtype: dict 85 | """ 86 | return obj.get_image_urls() 87 | 88 | @transaction.atomic 89 | def create(self, validated_data): 90 | """ 91 | Overwriting default create function to ensure, that the already existing instance of product is used, if asin is already in database 92 | 93 | :param validated_data: valid form data 94 | :type validated_data: dict 95 | :return: created or fetched product 96 | :rtype: Product 97 | """ 98 | # product = Product.objects.get_or_create(asin=validated_data['asin'])[0] 99 | try: 100 | product = Product.objects.get(asin__iexact=validated_data['asin']) 101 | except Product.DoesNotExist: 102 | product = Product.objects.create(asin=validated_data['asin']) 103 | 104 | for new_subscription in validated_data['subscription_set']: 105 | # first fetch EmailNotification object 106 | email_notification = EmailNotification.objects.get_or_create( 107 | owner=self.context['request'].user, 108 | email=new_subscription['email_notification']['email'] 109 | )[0] 110 | 111 | # don't create double subscriptions with same price limit 112 | product.subscription_set.get_or_create( 113 | owner=self.context['request'].user, 114 | price_limit=new_subscription['price_limit'], 115 | email_notification=email_notification 116 | ) 117 | return product 118 | 119 | def update(self, instance, validated_data): 120 | """ 121 | Overwrites parent function to enable update of products subscriptions 122 | 123 | :param instance: the product instance 124 | :type instance: Product 125 | :param validated_data: dict with validated data from request 126 | :type validated_data: dict 127 | :returns: Updated product instance (in fact there are only updates to subscriptions) 128 | :rtype: Product 129 | """ 130 | new_public_ids = [] 131 | for value_dict in validated_data['subscription_set']: 132 | # get public_id if there is any 133 | public_id = value_dict.get('public_id') 134 | new_public_ids.append(public_id) 135 | if public_id: 136 | subscription = Subscription.objects.get_or_create(public_id=public_id)[0] 137 | else: 138 | # this is a new line! 139 | subscription = Subscription() 140 | subscription.product = instance 141 | subscription.owner = self.context['request'].user 142 | 143 | subscription.price_limit = value_dict['price_limit'] 144 | # simply create email notifcation object if this is a new address 145 | subscription.email_notification = EmailNotification.objects.get_or_create( 146 | owner=self.context['request'].user, 147 | email=value_dict['email_notification']['email'] 148 | )[0] 149 | subscription.save() 150 | 151 | # remove all subscriptions not in new set subscriptions 152 | instance.subscription_set.filter(owner=self.context['request'].user).exclude(public_id__in=new_public_ids).delete() 153 | return self.context['view'].filter_queryset(self.context['view'].get_queryset()).get(pk=instance.pk) 154 | 155 | class Meta(object): 156 | 157 | """Some model meta""" 158 | 159 | model = Product 160 | fields = ( 161 | 'date_creation', 162 | 'date_updated', 163 | 'date_last_synced', 164 | 'status', 165 | 'subscription_set', 166 | 167 | # amazon specific fields 168 | 'asin', 169 | 'title', 170 | 'artist', 171 | 'isbn', 172 | 'eisbn', 173 | 'binding', 174 | 'date_publication', 175 | 'date_release', 176 | 177 | # amazon urls 178 | 'image_urls', 179 | 'offer_url', 180 | 'current_price', 181 | 'highest_price', 182 | 'lowest_price', 183 | ) 184 | # TODO: check if this is good 185 | read_only_fields = ( 186 | 'date_creation', 187 | 'date_updated', 188 | 'date_last_synced', 189 | 'status', 190 | 191 | # amazon specific fields 192 | 'title', 193 | 'artist', 194 | 'isbn', 195 | 'eisbn', 196 | 'author', 197 | 'publisher', 198 | 'label', 199 | 'manufacturer', 200 | 'brand', 201 | 'binding', 202 | 'pages', 203 | 'date_publication', 204 | 'date_release', 205 | 'edition', 206 | 'model', 207 | 'part_number', 208 | 209 | # amazon urls 210 | 'image_urls', 211 | 'offer_url', 212 | ) 213 | -------------------------------------------------------------------------------- /docker/web/project/glue/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for glue project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.8.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.8/ref/settings/ 11 | """ 12 | 13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 14 | import os 15 | 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = os.environ.get('SECRET_KEY') 24 | 25 | DEBUG = os.environ.get('DEBUG', False) 26 | 27 | ALLOWED_HOSTS = ['127.0.0.1', '0.0.0.0', ] 28 | 29 | # Application definition 30 | 31 | INSTALLED_APPS = [ 32 | 'django.contrib.admin', 33 | 'django.contrib.auth', 34 | 'django.contrib.contenttypes', 35 | 'django.contrib.sessions', 36 | 'django.contrib.messages', 37 | 'django.contrib.staticfiles', 38 | 'glue_auth', 39 | 'rest_framework', 40 | 'price_monitor', 41 | 'price_monitor.product_advertising_api', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'glue.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = 'glue.wsgi.application' 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 78 | 79 | DATABASES = { 80 | 'default': { 81 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 82 | 'NAME': os.environ.get('POSTGRES_DB'), 83 | 'USER': os.environ.get('POSTGRES_USER'), 84 | 'PASSWORD': os.environ.get('POSTGRES_PASSWORD'), 85 | 'HOST': 'db', 86 | 'PORT': '5432', 87 | } 88 | } 89 | 90 | 91 | # Internationalization 92 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 93 | 94 | LANGUAGE_CODE = 'en-us' 95 | 96 | TIME_ZONE = 'UTC' 97 | 98 | USE_I18N = True 99 | 100 | USE_L10N = True 101 | 102 | USE_TZ = True 103 | 104 | 105 | # Static files (CSS, JavaScript, Images) 106 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 107 | 108 | STATIC_URL = '/static/' 109 | STATIC_ROOT = '/srv/static/' 110 | 111 | # Logging 112 | LOGGING = { 113 | 'version': 1, 114 | 'disable_existing_loggers': False, 115 | 'formatters': { 116 | 'verbose': { 117 | 'format': '%(levelname)s %(asctime)s %(filename)s %(lineno)d %(message)s' 118 | }, 119 | 'simple': { 120 | 'format': '%(levelname)s %(message)s' 121 | }, 122 | }, 123 | 'handlers': { 124 | 'file_error': { 125 | 'level': 'ERROR', 126 | 'class': 'logging.FileHandler', 127 | 'filename': os.path.join(BASE_DIR, '..', 'logs', 'error.log'), 128 | 'formatter': 'verbose', 129 | }, 130 | 'price_monitor': { 131 | 'level': 'DEBUG', 132 | 'class': 'logging.FileHandler', 133 | 'filename': os.path.join(BASE_DIR, '..', 'logs', 'price_monitor.log'), 134 | 'formatter': 'verbose', 135 | }, 136 | 'price_monitor.product_advertising_api': { 137 | 'level': 'DEBUG', 138 | 'class': 'logging.FileHandler', 139 | 'filename': os.path.join(BASE_DIR, '..', 'logs', 'price_monitor.product_advertising_api.log'), 140 | 'formatter': 'verbose', 141 | }, 142 | 'price_monitor.tasks': { 143 | 'level': 'DEBUG', 144 | 'class': 'logging.FileHandler', 145 | 'filename': os.path.join(BASE_DIR, '..', 'logs', 'price_monitor.tasks.log'), 146 | 'formatter': 'verbose', 147 | }, 148 | 'price_monitor.utils': { 149 | 'level': 'DEBUG', 150 | 'class': 'logging.FileHandler', 151 | 'filename': os.path.join(BASE_DIR, '..', 'logs', 'price_monitor.utils.log'), 152 | 'formatter': 'verbose', 153 | }, 154 | 'mail_admins': { 155 | 'level': 'ERROR', 156 | 'class': 'django.utils.log.AdminEmailHandler', 157 | 'include_html': True, 158 | }, 159 | }, 160 | 'loggers': { 161 | 'django.request': { 162 | 'handlers': ['file_error', 'mail_admins'], 163 | 'level': 'ERROR', 164 | 'propagate': True, 165 | }, 166 | 'price_monitor': { 167 | 'handlers': ['price_monitor'], 168 | 'level': 'INFO', 169 | 'propagate': True, 170 | }, 171 | 'price_monitor.product_advertising_api': { 172 | 'handlers': ['price_monitor.product_advertising_api'], 173 | 'level': 'INFO', 174 | 'propagate': True, 175 | }, 176 | 'price_monitor.tasks': { 177 | 'handlers': ['price_monitor.tasks'], 178 | 'level': 'INFO', 179 | 'propagate': True, 180 | }, 181 | 'price_monitor.utils': { 182 | 'handlers': ['price_monitor.utils'], 183 | 'level': 'INFO', 184 | 'propagate': True, 185 | }, 186 | }, 187 | } 188 | 189 | # caching 190 | # CACHES = { 191 | # 'default': { 192 | # 'BACKEND': 'redis_cache.RedisCache', 193 | # 'LOCATION': 'redis', 194 | # 'OPTIONS': { 195 | # 'DB': 0, 196 | # 'PARSER_CLASS': 'redis.connection.HiredisParser', 197 | # 'CONNECTION_POOL_CLASS': 'redis.BlockingConnectionPool', 198 | # 'CONNECTION_POOL_CLASS_KWARGS': { 199 | # 'max_connections': 50, 200 | # 'timeout': 20, 201 | # } 202 | # }, 203 | # }, 204 | # } 205 | # CACHE_MIDDLEWARE_KEY_PREFIX = 'pm_glue' 206 | 207 | # glue login 208 | LOGIN_REDIRECT_URL = '/' 209 | LOGIN_URL = '/login/' 210 | 211 | # E-Mail 212 | EMAIL_BACKEND = os.environ.get('EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend') # smtp is the Django default 213 | if EMAIL_BACKEND == 'django.core.mail.backends.smtp.EmailBackend': 214 | EMAIL_HOST = os.environ.get('EMAIL_HOST') 215 | EMAIL_PORT = os.environ.get('EMAIL_PORT') 216 | EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER') 217 | EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') 218 | EMAIL_USE_TSL = os.environ.get('EMAIL_USE_TSL', True) 219 | elif EMAIL_BACKEND == 'django.core.mail.backends.filebased.EmailBackend': 220 | EMAIL_FILE_PATH = os.path.join(BASE_DIR, '..', 'logs', 'emails.out') 221 | 222 | # Celery 223 | CELERY_ACCEPT_CONTENT = ['pickle', 'json'] 224 | CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL') 225 | CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', '') 226 | CELERY_CHORD_PROPAGATES = True 227 | # redis specific, see http://celery.readthedocs.org/en/latest/getting-started/brokers/redis.html#caveats 228 | CELERY_BROKER_TRANSPORT_OPTIONS = { 229 | 'fanout_prefix': True, 230 | 'fanout_patterns': True, 231 | } 232 | 233 | # price_monitor 234 | PRICE_MONITOR_BASE_URL = os.environ.get('PRICE_MONITOR_BASE_URL', 'http://0.0.0.0:8000') 235 | PRICE_MONITOR_AWS_ACCESS_KEY_ID = os.environ.get('PRICE_MONITOR_AWS_ACCESS_KEY_ID', '') 236 | PRICE_MONITOR_AWS_SECRET_ACCESS_KEY = os.environ.get('PRICE_MONITOR_AWS_SECRET_ACCESS_KEY', '') 237 | PRICE_MONITOR_AMAZON_PRODUCT_API_REGION = os.environ.get('PRICE_MONITOR_AMAZON_PRODUCT_API_REGION', 'DE') 238 | PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG = os.environ.get('PRICE_MONITOR_AMAZON_PRODUCT_API_ASSOC_TAG', '') 239 | PRICE_MONITOR_AMAZON_ASSOCIATE_NAME = 'John Doe' 240 | PRICE_MONITOR_AMAZON_ASSOCIATE_SITE = 'Amazon.de' 241 | PRICE_MONITOR_EMAIL_SENDER = 'Amazon Pricemonitor ' 242 | PRICE_MONITOR_SITENAME = 'Pricemonitor Site' 243 | # refresh product after 1 hours 244 | PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES = os.environ.get('PRICE_MONITOR_AMAZON_PRODUCT_REFRESH_THRESHOLD_MINUTES', 60) 245 | # time after when to notify about a subscription again 246 | PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES = os.environ.get('PRICE_MONITOR_SUBSCRIPTION_RENOTIFICATION_MINUTES', 24 * 3 * 60) 247 | 248 | REST_FRAMEWORK = { 249 | 'PAGINATE_BY': 50, 250 | 'PAGINATE_BY_PARAM': 'page_size', # Allow client to override, using `?page_size=xxx`. 251 | 'MAX_PAGINATE_BY': 100 # Maximum limit allowed when using `?page_size=xxx`. 252 | } 253 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | TBA 4 | --- 5 | **Maintenance** 6 | 7 | - updated the following packages 8 | - ``Celery`` from ``3`` to ``4``, **the setting** ``BROKER_URL`` **is now named** ``CELERY_BROKER_URL`` 9 | - ``Django`` up to ``1.11`` 10 | - ``CairoSVG`` is not pinned to version below ``2`` any more, with this we drop support for Python ``3.3`` as it is not compatible 11 | - dropped support for ``Python 3.3`` 12 | 13 | **Bugfixes:** 14 | 15 | - fixed handling of ISBN-13 values in the ISBN-10 return value from Amazon Product Advertising API `#121 `__ (`PR#122 `__) 16 | 17 | `0.7 `__ 18 | ---------------------------------------------------------------------- 19 | **Features:** 20 | 21 | - footer can now be extended through template block *footer* 22 | - product addition in frontend improved `#79 `__ (`PR#104 `__) 23 | - removed ``urlpatterns`` to please Django 1.10 deprecation 24 | - added docker setup for development (`PR#101 `__) 25 | - list products with audience rating 18+ in notification mail if region is Germany and product is also 18+ `#92 `__ (`PR#93 `__) 26 | 27 | **Bugfixes:** 28 | 29 | - now catching parsing errors of returned XML from Amazon API `#96 `__ 30 | - fixed date range of displayed prices in price graph `#90 `__ 31 | - fixed display of old prices of price graph `#97 `__ 32 | - updated to latest ``python-dateutil`` version, somehow refs `#95 `__ 33 | 34 | `0.6.1 `__ 35 | -------------------------------------------------------------------------- 36 | **Bugfixes:** 37 | 38 | - StartupTask fails with exception `#94 `__ 39 | - Tests fail if today is the last day of November `#95 `__ 40 | 41 | `0.6 `__ 42 | ---------------------------------------------------------------------- 43 | **Features:** 44 | 45 | - djangorestframework 3.2 compatibility `#86 `__ (`PR#88 `__) 46 | 47 | **Bugfixes:** 48 | 49 | - FindProductsToSynchronizeTask is rescheduled twice or more `#89 `__ (`PR#91 `__) 50 | - Unable to parse 2015-02 to a datetime `#57 `__ 51 | - lots of codestyle 52 | - minor bugfixes 53 | 54 | `0.5 `__ 55 | ---------------------------------------------------------------------- 56 | **Features:** 57 | 58 | - Add link to PM frontend in notification email `#76 `__ 59 | - Django 1.9 support (see `pull request #80 `__) 60 | 61 | **Bugfixes:** 62 | 63 | - FindProductsToSynchronizeTask is not always rescheduled `#61 `__ 64 | - Font files not included in package `#75 `__ 65 | - Identify as Amazon associate `#77 `__ 66 | 67 | **Pull requests:** 68 | 69 | - Ensured that FindProductsToSynchronizeTask will be scheduled `#78 `__ (`dArignac `__) 70 | - Django 1.9 support `#80 `__ (`dArignac `__) 71 | 72 | `0.4 `__ 73 | ---------------------------------------------------------------------- 74 | **Features:** 75 | 76 | - Deprecate old frontend `#73 `__ 77 | - Make angular the default frontend `#70 `__ 78 | 79 | **Bugfixes:** 80 | 81 | - Products with the same price over graph timespae have an empty graph `#67 `__ 82 | - Notification of music albums `#33 `__ 83 | - Add artist for audio products `#71 `__ 84 | 85 | **Pull requests:** 86 | 87 | - Remove old frontend `#74 `__ (`dArignac `__) 88 | - Fix for empty graphs is packaged now #67 `#72 `__ (`mmrose `__) 89 | 90 | `0.3b2 `__ 91 | -------------------------------------------------------------------------- 92 | **Features:** 93 | 94 | - Prepare for automatic releases `#68 `__ 95 | - Increase performance of Amazon calls `#41 `__ 96 | - Django 1.8 compatibility `#32 `__ 97 | - Data reduction and clean up `#27 `__ 98 | - Limit graphs `#26 `__ 99 | - Show highest and lowest price ever `#25 `__ 100 | - Implement a full-usable frontend`#8 `__ 101 | - Add more tests `#2 `__ 102 | 103 | **Bugfixes:** 104 | 105 | - Graphs empty for some products `#65 `__ 106 | - Don't show other peoples price limits `#63 `__ 107 | - Graphs do not render correct values `#60 `__ 108 | - 'NoneType' object has no attribute 'url' `#59 `__ 109 | - Rename SynchronizeSingleProductTask `#56 `__ 110 | - Sync on product creation not working `#55 `__ 111 | - Clear old products and prices `#47 `__ 112 | - Deleting a product subscription does not remove it from list view `#42 `__ 113 | - Endless synchronization queue `#38 `__ 114 | - Mark unavailable products `#14 `__ 115 | 116 | **Closed issues:** 117 | 118 | - Unpin beautifulsoup4==4.3.2 `#50 `__ 119 | 120 | **Pull requests:** 121 | 122 | - fixed access of unavilable image urls #59 `#66 `__ (`dArignac `__) 123 | - 63 subscriptions of other users `#64 `__ (`mmrose `__) 124 | - Mark unavailable products `#62 `__ (`mmrose `__) 125 | - Sync on product creation not working `#58 `__ (`dArignac `__) 126 | - Products are now requeried after deletion in list view #42 `#54 `__ (`mmrose `__) 127 | - Show highest and lowest price (#25) `#53 `__ (`mmrose `__) 128 | - Now the new FKs are also set during sync #25 `#52 `__ (`mmrose `__) 129 | - Adding datamigration for new min, max and current price FKs #25 `#51 `__ (`mmrose `__) 130 | - Performance improvements on product API view `#49 `__ (`mmrose `__) 131 | - Remove unused data`#48 `__ (`dArignac `__) 132 | - Amazon query performance increase `#46 `__ (`dArignac `__) 133 | - Django 1.8 compatibility `#45 `__ (`dArignac `__) 134 | - Bugfix: Endless queue `#40 `__ (`dArignac `__) 135 | - waffle.io Badge `#37 `__ (`waffle-iron `__) 136 | 137 | Pre-Releases 138 | ------------ 139 | - unfortunately everything before was not packaged and released nor tracked. -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/bootstrap/css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.1 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | .btn-default,.btn-primary,.btn-success,.btn-info,.btn-warning,.btn-danger{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-default:active,.btn-primary:active,.btn-success:active,.btn-info:active,.btn-warning:active,.btn-danger:active,.btn-default.active,.btn-primary.active,.btn-success.active,.btn-info.active,.btn-warning.active,.btn-danger.active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn:active,.btn.active{background-image:none}.btn-default{background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;text-shadow:0 1px 0 #fff;border-color:#ccc}.btn-default:hover,.btn-default:focus{background-color:#e0e0e0;background-position:0 -15px}.btn-default:active,.btn-default.active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-primary{background-image:-webkit-linear-gradient(top,#428bca 0,#2d6ca2 100%);background-image:linear-gradient(to bottom,#428bca 0,#2d6ca2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff2d6ca2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#2b669a}.btn-primary:hover,.btn-primary:focus{background-color:#2d6ca2;background-position:0 -15px}.btn-primary:active,.btn-primary.active{background-color:#2d6ca2;border-color:#2b669a}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:hover,.btn-success:focus{background-color:#419641;background-position:0 -15px}.btn-success:active,.btn-success.active{background-color:#419641;border-color:#3e8f3e}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:hover,.btn-info:focus{background-color:#2aabd2;background-position:0 -15px}.btn-info:active,.btn-info.active{background-color:#2aabd2;border-color:#28a4c9}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:hover,.btn-warning:focus{background-color:#eb9316;background-position:0 -15px}.btn-warning:active,.btn-warning.active{background-color:#eb9316;border-color:#e38d13}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:hover,.btn-danger:focus{background-color:#c12e2a;background-position:0 -15px}.btn-danger:active,.btn-danger.active{background-color:#c12e2a;border-color:#b92c28}.thumbnail,.img-thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-color:#e8e8e8}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0);background-color:#357ebd}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f3f3f3 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f3f3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff3f3f3', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.navbar-inverse .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#222 0,#282828 100%);background-image:linear-gradient(to bottom,#222 0,#282828 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff282828', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-static-top,.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0)}.progress-bar{background-image:-webkit-linear-gradient(top,#428bca 0,#3071a9 100%);background-image:linear-gradient(to bottom,#428bca 0,#3071a9 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3071a9', GradientType=0)}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0)}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0)}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0)}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{text-shadow:0 -1px 0 #3071a9;background-image:-webkit-linear-gradient(top,#428bca 0,#3278b3 100%);background-image:linear-gradient(to bottom,#428bca 0,#3278b3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3278b3', GradientType=0);border-color:#3278b3}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0)}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0)}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0)}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0)}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0)}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0)}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} -------------------------------------------------------------------------------- /price_monitor/static/price_monitor/bootstrap/css/bootstrap-theme.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.1 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | .btn-default, 8 | .btn-primary, 9 | .btn-success, 10 | .btn-info, 11 | .btn-warning, 12 | .btn-danger { 13 | text-shadow: 0 -1px 0 rgba(0, 0, 0, .2); 14 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); 15 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); 16 | } 17 | .btn-default:active, 18 | .btn-primary:active, 19 | .btn-success:active, 20 | .btn-info:active, 21 | .btn-warning:active, 22 | .btn-danger:active, 23 | .btn-default.active, 24 | .btn-primary.active, 25 | .btn-success.active, 26 | .btn-info.active, 27 | .btn-warning.active, 28 | .btn-danger.active { 29 | -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); 30 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); 31 | } 32 | .btn:active, 33 | .btn.active { 34 | background-image: none; 35 | } 36 | .btn-default { 37 | text-shadow: 0 1px 0 #fff; 38 | background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%); 39 | background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%); 40 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); 41 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 42 | background-repeat: repeat-x; 43 | border-color: #dbdbdb; 44 | border-color: #ccc; 45 | } 46 | .btn-default:hover, 47 | .btn-default:focus { 48 | background-color: #e0e0e0; 49 | background-position: 0 -15px; 50 | } 51 | .btn-default:active, 52 | .btn-default.active { 53 | background-color: #e0e0e0; 54 | border-color: #dbdbdb; 55 | } 56 | .btn-primary { 57 | background-image: -webkit-linear-gradient(top, #428bca 0%, #2d6ca2 100%); 58 | background-image: linear-gradient(to bottom, #428bca 0%, #2d6ca2 100%); 59 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff2d6ca2', GradientType=0); 60 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 61 | background-repeat: repeat-x; 62 | border-color: #2b669a; 63 | } 64 | .btn-primary:hover, 65 | .btn-primary:focus { 66 | background-color: #2d6ca2; 67 | background-position: 0 -15px; 68 | } 69 | .btn-primary:active, 70 | .btn-primary.active { 71 | background-color: #2d6ca2; 72 | border-color: #2b669a; 73 | } 74 | .btn-success { 75 | background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%); 76 | background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%); 77 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0); 78 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 79 | background-repeat: repeat-x; 80 | border-color: #3e8f3e; 81 | } 82 | .btn-success:hover, 83 | .btn-success:focus { 84 | background-color: #419641; 85 | background-position: 0 -15px; 86 | } 87 | .btn-success:active, 88 | .btn-success.active { 89 | background-color: #419641; 90 | border-color: #3e8f3e; 91 | } 92 | .btn-info { 93 | background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); 94 | background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%); 95 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0); 96 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 97 | background-repeat: repeat-x; 98 | border-color: #28a4c9; 99 | } 100 | .btn-info:hover, 101 | .btn-info:focus { 102 | background-color: #2aabd2; 103 | background-position: 0 -15px; 104 | } 105 | .btn-info:active, 106 | .btn-info.active { 107 | background-color: #2aabd2; 108 | border-color: #28a4c9; 109 | } 110 | .btn-warning { 111 | background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); 112 | background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%); 113 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0); 114 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 115 | background-repeat: repeat-x; 116 | border-color: #e38d13; 117 | } 118 | .btn-warning:hover, 119 | .btn-warning:focus { 120 | background-color: #eb9316; 121 | background-position: 0 -15px; 122 | } 123 | .btn-warning:active, 124 | .btn-warning.active { 125 | background-color: #eb9316; 126 | border-color: #e38d13; 127 | } 128 | .btn-danger { 129 | background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%); 130 | background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%); 131 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0); 132 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 133 | background-repeat: repeat-x; 134 | border-color: #b92c28; 135 | } 136 | .btn-danger:hover, 137 | .btn-danger:focus { 138 | background-color: #c12e2a; 139 | background-position: 0 -15px; 140 | } 141 | .btn-danger:active, 142 | .btn-danger.active { 143 | background-color: #c12e2a; 144 | border-color: #b92c28; 145 | } 146 | .thumbnail, 147 | .img-thumbnail { 148 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 149 | box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 150 | } 151 | .dropdown-menu > li > a:hover, 152 | .dropdown-menu > li > a:focus { 153 | background-color: #e8e8e8; 154 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 155 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); 156 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); 157 | background-repeat: repeat-x; 158 | } 159 | .dropdown-menu > .active > a, 160 | .dropdown-menu > .active > a:hover, 161 | .dropdown-menu > .active > a:focus { 162 | background-color: #357ebd; 163 | background-image: -webkit-linear-gradient(top, #428bca 0%, #357ebd 100%); 164 | background-image: linear-gradient(to bottom, #428bca 0%, #357ebd 100%); 165 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0); 166 | background-repeat: repeat-x; 167 | } 168 | .navbar-default { 169 | background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%); 170 | background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%); 171 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0); 172 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 173 | background-repeat: repeat-x; 174 | border-radius: 4px; 175 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); 176 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); 177 | } 178 | .navbar-default .navbar-nav > .active > a { 179 | background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f3f3f3 100%); 180 | background-image: linear-gradient(to bottom, #ebebeb 0%, #f3f3f3 100%); 181 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff3f3f3', GradientType=0); 182 | background-repeat: repeat-x; 183 | -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); 184 | box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); 185 | } 186 | .navbar-brand, 187 | .navbar-nav > li > a { 188 | text-shadow: 0 1px 0 rgba(255, 255, 255, .25); 189 | } 190 | .navbar-inverse { 191 | background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%); 192 | background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%); 193 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0); 194 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 195 | background-repeat: repeat-x; 196 | } 197 | .navbar-inverse .navbar-nav > .active > a { 198 | background-image: -webkit-linear-gradient(top, #222 0%, #282828 100%); 199 | background-image: linear-gradient(to bottom, #222 0%, #282828 100%); 200 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff282828', GradientType=0); 201 | background-repeat: repeat-x; 202 | -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); 203 | box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); 204 | } 205 | .navbar-inverse .navbar-brand, 206 | .navbar-inverse .navbar-nav > li > a { 207 | text-shadow: 0 -1px 0 rgba(0, 0, 0, .25); 208 | } 209 | .navbar-static-top, 210 | .navbar-fixed-top, 211 | .navbar-fixed-bottom { 212 | border-radius: 0; 213 | } 214 | .alert { 215 | text-shadow: 0 1px 0 rgba(255, 255, 255, .2); 216 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); 217 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); 218 | } 219 | .alert-success { 220 | background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); 221 | background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%); 222 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0); 223 | background-repeat: repeat-x; 224 | border-color: #b2dba1; 225 | } 226 | .alert-info { 227 | background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%); 228 | background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%); 229 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0); 230 | background-repeat: repeat-x; 231 | border-color: #9acfea; 232 | } 233 | .alert-warning { 234 | background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); 235 | background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%); 236 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0); 237 | background-repeat: repeat-x; 238 | border-color: #f5e79e; 239 | } 240 | .alert-danger { 241 | background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); 242 | background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%); 243 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0); 244 | background-repeat: repeat-x; 245 | border-color: #dca7a7; 246 | } 247 | .progress { 248 | background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); 249 | background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%); 250 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0); 251 | background-repeat: repeat-x; 252 | } 253 | .progress-bar { 254 | background-image: -webkit-linear-gradient(top, #428bca 0%, #3071a9 100%); 255 | background-image: linear-gradient(to bottom, #428bca 0%, #3071a9 100%); 256 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3071a9', GradientType=0); 257 | background-repeat: repeat-x; 258 | } 259 | .progress-bar-success { 260 | background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%); 261 | background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%); 262 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0); 263 | background-repeat: repeat-x; 264 | } 265 | .progress-bar-info { 266 | background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); 267 | background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%); 268 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0); 269 | background-repeat: repeat-x; 270 | } 271 | .progress-bar-warning { 272 | background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); 273 | background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%); 274 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0); 275 | background-repeat: repeat-x; 276 | } 277 | .progress-bar-danger { 278 | background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%); 279 | background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%); 280 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0); 281 | background-repeat: repeat-x; 282 | } 283 | .list-group { 284 | border-radius: 4px; 285 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 286 | box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 287 | } 288 | .list-group-item.active, 289 | .list-group-item.active:hover, 290 | .list-group-item.active:focus { 291 | text-shadow: 0 -1px 0 #3071a9; 292 | background-image: -webkit-linear-gradient(top, #428bca 0%, #3278b3 100%); 293 | background-image: linear-gradient(to bottom, #428bca 0%, #3278b3 100%); 294 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3278b3', GradientType=0); 295 | background-repeat: repeat-x; 296 | border-color: #3278b3; 297 | } 298 | .panel { 299 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 300 | box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 301 | } 302 | .panel-default > .panel-heading { 303 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 304 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); 305 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); 306 | background-repeat: repeat-x; 307 | } 308 | .panel-primary > .panel-heading { 309 | background-image: -webkit-linear-gradient(top, #428bca 0%, #357ebd 100%); 310 | background-image: linear-gradient(to bottom, #428bca 0%, #357ebd 100%); 311 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0); 312 | background-repeat: repeat-x; 313 | } 314 | .panel-success > .panel-heading { 315 | background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); 316 | background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%); 317 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0); 318 | background-repeat: repeat-x; 319 | } 320 | .panel-info > .panel-heading { 321 | background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); 322 | background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%); 323 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0); 324 | background-repeat: repeat-x; 325 | } 326 | .panel-warning > .panel-heading { 327 | background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); 328 | background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%); 329 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0); 330 | background-repeat: repeat-x; 331 | } 332 | .panel-danger > .panel-heading { 333 | background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%); 334 | background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%); 335 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0); 336 | background-repeat: repeat-x; 337 | } 338 | .well { 339 | background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); 340 | background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%); 341 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0); 342 | background-repeat: repeat-x; 343 | border-color: #dcdcdc; 344 | -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); 345 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); 346 | } 347 | /*# sourceMappingURL=bootstrap-theme.css.map */ 348 | --------------------------------------------------------------------------------