diff --git a/apps/constants/payments.py b/apps/constants/payments.py index e219420e8cd..bf80b6a2ff5 100644 --- a/apps/constants/payments.py +++ b/apps/constants/payments.py @@ -73,6 +73,13 @@ CONTRIB_VOLUNTARY: _('Voluntary'), } +MKT_TRANSACTION_CONTRIB_TYPES = { + CONTRIB_CHARGEBACK: _('Chargeback'), + CONTRIB_INAPP: _('In-app Purchase'), + CONTRIB_PURCHASE: _('Purchase'), + CONTRIB_REFUND: _('Refund'), +} + CONTRIB_TYPE_DEFAULT = CONTRIB_VOLUNTARY INAPP_STATUS_ACTIVE = 0 diff --git a/apps/stats/models.py b/apps/stats/models.py index 527576e1a08..1adb9ea5c85 100644 --- a/apps/stats/models.py +++ b/apps/stats/models.py @@ -14,7 +14,7 @@ import amo from amo.helpers import absolutify, urlparams -from amo.models import ModelBase, SearchMixin +from amo.models import SearchMixin from amo.fields import DecimalCharField from amo.utils import send_mail, send_mail_jinja from zadmin.models import DownloadSource @@ -430,12 +430,12 @@ def get_or_create(cls, request): else: lang = translation.get_language() client_data, c = cls.objects.get_or_create( - download_source=download_source, - device_type=request.POST.get('device_type', ''), - user_agent=request.META.get('HTTP_USER_AGENT', ''), - is_chromeless=request.POST.get('chromeless', False), - language=lang, - region=region) + download_source=download_source, + device_type=request.POST.get('device_type', ''), + user_agent=request.META.get('HTTP_USER_AGENT', ''), + is_chromeless=request.POST.get('chromeless', False), + language=lang, + region=region) return client_data class Meta: diff --git a/apps/stats/tasks.py b/apps/stats/tasks.py index 0d7984d5484..5cf70146c19 100644 --- a/apps/stats/tasks.py +++ b/apps/stats/tasks.py @@ -5,7 +5,6 @@ from django.db import connection, transaction from django.db.models import Sum, Max - from apiclient.discovery import build import requests import commonware.log diff --git a/media/css/devreg/transactions.less b/media/css/devreg/transactions.less new file mode 100644 index 00000000000..2b76a4b0ff8 --- /dev/null +++ b/media/css/devreg/transactions.less @@ -0,0 +1,42 @@ +@import 'lib'; + +th.amount, td.amount { + text-align: right; +} + +#tx-filters { + margin-bottom: 52px; + label { + display: inline-block; + margin-right: 5px; + text-align: right; + width: 110px; + } + .date-to label { + width: 50px + } + button { + bottom: 26px; + float: right; + position: relative; + } + .form-elem, .errorlist, .errorlist li { + display: inline; + } + .errorlist li { + left: 5px; + position: relative; + } +} +.date-from { + float: left; +} +.form-row { + margin-bottom: 13px; +} + +.results-found { + clear: both; + display: block; + margin-bottom: 13px; +} diff --git a/media/css/mkt/data-grid.less b/media/css/mkt/data-grid.less new file mode 100644 index 00000000000..30b382ae796 --- /dev/null +++ b/media/css/mkt/data-grid.less @@ -0,0 +1,146 @@ +@import '../devreg/lib'; + +/* data grids */ +.data-grid-top { + border-bottom: 1px solid #A5BFCE; +} +.data-grid-bottom { + border-top: 1px solid #A5BFCE; +} +.data-grid-content { + padding: 5px; + + ol.pagination { + margin: 0; + } + &:after { + content: "."; + display: block; + clear: both; + height: 0; + visibility: hidden; + } +} + +table.data-grid { + margin-bottom: 0; + width: 100%; + thead th { + background: @faded-blue; + a { + &:active, &:hover, &:visited { + color: #36b; + } + } + &.ordered { + .gradient-two-color(@faded-blue, @border-blue); + a { + color: #137; + &:active, &:hover, &:visited { + color: #039; + } + } + } + } + tr { + td { + border-top: 1px dotted @border-gray; + } + a { + display: block; + } + &:nth-child(odd) { + background-color: #FAFAFA; + } + &:nth-child(even) { + background-color: @white; + } + } + .addon-locked { + width: 16px; + height: 16px; + position: relative; + top: 4px; + } + .locked .addon-locked { + background: url(../../img/mkt/icons/mkt-reviewer-icons.png) no-repeat top left; + background-position: -16px -16px; + } + ul { + margin: 0; + } +} + +@media (min-width: @tablet-min) { + .data-grid { + thead th { + &:nth-child(3) { + min-width: 7em; + } + &:nth-child(4) { + min-width: 8em; + } + &:nth-child(5) { + min-width: 90px; + } + &:nth-child(6) { + min-width: 7em; + } + &:nth-child(7) { + min-width: 7em; + } + &:nth-child(8) { + min-width: 10em; + } + } + tr { + th, td { + padding: 7px 10px; + } + } + th { + line-height: 1.3; + } + .addon-row a { + max-width: 600px; + } + } +} + +@media (max-width: @landscape-max) { + .data-grid { + tr { + th, td { + border: 1px solid @faint-gray; + line-height: 12px; + font-size: 11px; + padding: 4px 2px; + text-align: center; + } + // Don't add borders around table cell for locked icon. + th:nth-child(1), + td:nth-child(1) { + border-right: 0; + } + th:nth-child(2), + td:nth-child(2) { + border-left: 0; + } + } + thead th { + &:nth-child(2) { + max-width: 300px; + } + &:nth-child(6) { + min-width: 55px; + } + &:nth-child(7) { + min-width: 45px; + } + } + th { + font-weight: 400; + vertical-align: middle; + } + } +} diff --git a/media/css/mkt/reviewers.less b/media/css/mkt/reviewers.less index 775072d3ea3..e55e3517ae1 100644 --- a/media/css/mkt/reviewers.less +++ b/media/css/mkt/reviewers.less @@ -230,27 +230,6 @@ header { .ed-sprite-sort-desc { background-position: right -435px; } -/* data grids on queue pages */ -.data-grid-top { - border-bottom: 1px solid #A5BFCE; -} -.data-grid-bottom { - border-top: 1px solid #A5BFCE; -} -.data-grid-content { - padding: 5px; - - ol.pagination { - margin: 0; - } - &:after { - content: "."; - display: block; - clear: both; - height: 0; - visibility: hidden; - } -} .queue-outer { .border-radius(5px); border: 4px solid #E0EFFD; @@ -273,34 +252,6 @@ header { @media (min-width: @tablet-min) { .data-grid { - thead th { - &:nth-child(3) { - min-width: 7em; - } - &:nth-child(4) { - min-width: 8em; - } - &:nth-child(5) { - min-width: 90px; - } - &:nth-child(6) { - min-width: 7em; - } - &:nth-child(7) { - min-width: 7em; - } - &:nth-child(8) { - min-width: 10em; - } - } - tr { - th, td { - padding: 7px 10px; - } - } - th { - line-height: 1.3; - } .addon-row a { max-width: 600px; } @@ -311,63 +262,6 @@ header { width: 121px; } -table.data-grid { - margin-bottom: 0; - width: 100%; - thead th { - background: @faded-blue; - &:first-child { - width: 0; - } - a { - &:active, &:hover, &:visited { - color: #36b; - } - } - &.ordered { - .gradient-two-color(@faded-blue, @border-blue); - a { - color: #137; - &:active, &:hover, &:visited { - color: #039; - } - } - } - } - tr { - td { - border-top: 1px dotted @border-gray; - } - a { - display: block; - } - &:nth-child(odd) { - background-color: #FAFAFA; - } - &:nth-child(even) { - background-color: @white; - } - } - .addon-locked { - width: 16px; - height: 16px; - position: relative; - top: 4px; - } - .locked .addon-locked { - background: url(../../img/mkt/icons/mkt-reviewer-icons.png) no-repeat top left; - background-position: -16px -16px; - } - ul { - margin: 0; - } - time { - color: @medium-gray; - font-size: 11px; - line-height: 12px; - } -} - /* Version notes */ #popup-notes .version-notes { overflow: auto; @@ -559,6 +453,11 @@ table.data-grid tr.comments td { border-top: none; background: #def; } +thead th { + &:first-child { + width: 0; + } +} .waiting_old, .waiting_med, .waiting_new { height: 20px; @@ -1368,39 +1267,6 @@ iframe#manifest-contents { } } .data-grid { - tr { - th, td { - border: 1px solid @faint-gray; - line-height: 12px; - font-size: 11px; - padding: 4px 2px; - text-align: center; - } - // Don't add borders around table cell for locked icon. - th:nth-child(1), - td:nth-child(1) { - border-right: 0; - } - th:nth-child(2), - td:nth-child(2) { - border-left: 0; - } - } - thead th { - &:nth-child(2) { - max-width: 300px; - } - &:nth-child(6) { - min-width: 55px; - } - &:nth-child(7) { - min-width: 45px; - } - } - th { - font-weight: 400; - vertical-align: middle; - } .addon-row a { display: block; font-size: 12px; diff --git a/migrations/519-view-tx-waffle b/migrations/519-view-tx-waffle new file mode 100644 index 00000000000..05135114acb --- /dev/null +++ b/migrations/519-view-tx-waffle @@ -0,0 +1,3 @@ +INSERT INTO waffle_switch_mkt (name, active, created, modified, note) + VALUES ('view-transactions', 0, NOW(), NOW(), + 'Enable transaction pages on Marketplace.'); diff --git a/mkt/asset_bundles.py b/mkt/asset_bundles.py index 09dbde7fa38..e22136c7034 100644 --- a/mkt/asset_bundles.py +++ b/mkt/asset_bundles.py @@ -35,6 +35,9 @@ 'css/common/forms.less', 'css/devreg/devhub-forms.less', + # Tables. + 'css/mkt/data-grid.less', + # Landing page 'css/devreg/landing.less', @@ -45,6 +48,7 @@ 'css/devreg/in-app-config.less', 'css/devreg/payments.less', 'css/devreg/refunds.less', + 'css/devreg/transactions.less', 'css/devreg/status.less', # Image Uploads (used for "Edit Listing" Images and Submission). @@ -72,6 +76,7 @@ 'mkt/reviewers': ( 'css/mkt/buttons.less', 'css/mkt/ratings.less', + 'css/mkt/data-grid.less', 'css/mkt/reviewers.less', 'css/mkt/themes_review.less', 'css/mkt/paginator.less', diff --git a/mkt/developers/forms.py b/mkt/developers/forms.py index 3e57be1faff..c0bb858f66f 100644 --- a/mkt/developers/forms.py +++ b/mkt/developers/forms.py @@ -5,6 +5,7 @@ from django import forms from django.conf import settings +from django.forms.extras.widgets import SelectDateWidget from django.forms.models import formset_factory, modelformset_factory from django.template.defaultfilters import filesizeformat @@ -34,6 +35,7 @@ from mkt.constants import APP_IMAGE_SIZES, MAX_PACKAGED_APP_SIZE from mkt.constants.ratingsbodies import (RATINGS_BY_NAME, ALL_RATINGS, RATINGS_BODIES) +from mkt.site.forms import AddonChoiceField from mkt.webapps.models import (AddonExcludedRegion, ContentRating, ImageAsset, Webapp) @@ -849,3 +851,26 @@ def save(self, addon, commit=False): af.update(uses_flash=bool(uses_flash)) return super(AppFormTechnical, self).save(commit=True) + + +class TransactionFilterForm(happyforms.Form): + app = AddonChoiceField(queryset=None, required=False, label=_lazy(u'App')) + transaction_type = forms.ChoiceField( + required=False, label=_lazy(u'Transaction Type'), + choices=[(None, '')] + amo.MKT_TRANSACTION_CONTRIB_TYPES.items()) + transaction_id = forms.CharField( + required=False, label=_lazy(u'Transaction ID')) + + current_year = datetime.today().year + years = [current_year - x for x in range(current_year - 2012)] + date_from = forms.DateTimeField( + required=False, widget=SelectDateWidget(years=years), + label=_lazy(u'From')) + date_to = forms.DateTimeField( + required=False, widget=SelectDateWidget(years=years), + label=_lazy(u'To')) + + def __init__(self, *args, **kwargs): + self.apps = kwargs.pop('apps', []) + super(TransactionFilterForm, self).__init__(*args, **kwargs) + self.fields['app'].queryset = self.apps diff --git a/mkt/developers/templates/developers/transactions.html b/mkt/developers/templates/developers/transactions.html new file mode 100644 index 00000000000..7f784c8be44 --- /dev/null +++ b/mkt/developers/templates/developers/transactions.html @@ -0,0 +1,67 @@ +{% extends 'developers/base_impala.html' %} +{% from 'site/helpers/form_row.html' import form_row %} + +{% set title = _('Transaction Details') %} +{% block title %}{{ hub_page_title(title) }}{% endblock %} + +{% block content %} +
+ {{ hub_breadcrumbs(addon, items=[(None, title)]) }} +

{{ title }}

+
+ +
+
+ {{ form_row(form, ('app',)) }} + {{ form_row(form, ('transaction_type',)) }} + {{ form_row(form, ('transaction_id',)) }} +
+ {{ form_row(form, ('date_from',)) }} +
+
+ {{ form_row(form, ('date_to',)) }} +
+ +
+
+ + {{ ngettext('{num} transaction found', + '{num} transactions found', + count)|f(num=count) }} + +
+ {% if not transactions.paginator.count %} +

+ {{ _('Your apps currently have no transactions.') }} +

+ {% else %} + + + + + + + + + + + + + {% for transaction in transactions.object_list %} + + + + + + + {% if transaction.amount %} + + {% endif %} + + {% endfor %} + +
{{ _('App') }}{{ _('Date') }}{{ _('Type') }}{{ _('Transaction ID') }}{{ _('Currency') }}{{ _('Amount') }}
{{ transaction.addon.name }}{{ transaction.created|datetime }}{{ CONTRIB_TYPES[transaction.type] }}{{ transaction.transaction_id }}{{ transaction.currency }}{{ transaction.amount|numberfmt }}
+ {% endif %} +
+ {{ transactions|impala_paginator }} +{% endblock %} diff --git a/mkt/developers/tests/test_forms.py b/mkt/developers/tests/test_forms.py index 1c06c0a8855..01348b6c553 100644 --- a/mkt/developers/tests/test_forms.py +++ b/mkt/developers/tests/test_forms.py @@ -12,13 +12,13 @@ import amo import amo.tests +from amo.tests import app_factory from amo.tests.test_helpers import get_image_path from addons.models import Addon, AddonCategory, Category from files.helpers import copyfileobj import mkt from mkt.developers import forms -from mkt.site.fixtures import fixture from mkt.webapps.models import (AddonExcludedRegion as AER, ContentRating, Webapp) @@ -283,3 +283,34 @@ def test_too_big(self): eq_(validation['messages'][0]['message'], [u'Packaged app too large for submission.', u'Packages must be less than 5 bytes.']) + + +class TestTransactionFilterForm(amo.tests.TestCase): + + def setUp(self): + (app_factory(), app_factory()) + # Need queryset to initialize form. + self.apps = Webapp.objects.all() + self.data = { + 'app': self.apps[0].id, + 'transaction_type': 1, + 'transaction_id': 1, + 'date_from_day': '1', + 'date_from_month': '1', + 'date_from_year': '2012', + 'date_to_day': '1', + 'date_to_month': '1', + 'date_to_year': '2013', + } + + def test_basic(self): + """Test the form doesn't crap out.""" + form = forms.TransactionFilterForm(self.data, apps=self.apps) + eq_(form.is_valid(), True) + + def test_app_choices(self): + """Test app choices.""" + form = forms.TransactionFilterForm(self.data, apps=self.apps) + for app in self.apps: + assertion = (app.id, app.name) in form.fields['app'].choices + assert assertion, '(%s, %s) not in choices' % (app.id, app.name) diff --git a/mkt/developers/tests/test_views.py b/mkt/developers/tests/test_views.py index 9bc787499d9..b453f1e3be9 100644 --- a/mkt/developers/tests/test_views.py +++ b/mkt/developers/tests/test_views.py @@ -8,6 +8,7 @@ from django.conf import settings from django.core.files.storage import default_storage as storage from django.core.files.uploadedfile import SimpleUploadedFile +from django.test.client import RequestFactory import mock import waffle @@ -19,7 +20,7 @@ import amo import amo.tests from addons.models import Addon, AddonDeviceType, AddonUpsell, AddonUser -from amo.tests import assert_no_validation_errors +from amo.tests import app_factory, assert_no_validation_errors from amo.tests.test_helpers import get_image_path from amo.urlresolvers import reverse from amo.utils import urlparams @@ -27,6 +28,7 @@ from files.models import FileUpload from files.tests.test_models import UploadTest as BaseUploadTest from market.models import AddonPremium, Price +from stats.models import Contribution from translations.models import Translation from users.models import UserProfile from versions.models import Version @@ -35,6 +37,8 @@ from mkt.constants import MAX_PACKAGED_APP_SIZE from mkt.developers import tasks from mkt.developers.models import ActivityLog +from mkt.developers.views import _filter_transactions, _get_transactions +from mkt.site.fixtures import fixture from mkt.submit.models import AppSubmissionChecklist from mkt.webapps.models import Webapp @@ -1039,3 +1043,72 @@ def test_not_past(self): eq_(doc('#site-notice').length, 0) eq_(doc('#dev-agreement').length, 1) eq_(doc('#agreement-form').length, 0) + + +class TestTransactionList(amo.tests.TestCase): + fixtures = fixture('user_999') + + def setUp(self): + """Create and set up apps for some filtering fun.""" + self.create_switch(name='view-transactions') + self.url = reverse('mkt.developers.transactions') + self.client.login(username='regular@mozilla.com', password='password') + + self.apps = [app_factory(), app_factory()] + self.user = UserProfile.objects.get(id=999) + for app in self.apps: + AddonUser.objects.create(addon=app, user=self.user) + + # Set up transactions. + tx0 = Contribution.objects.create(addon=self.apps[0], + type=amo.CONTRIB_PURCHASE, + transaction_id=12345) + tx1 = Contribution.objects.create(addon=self.apps[1], + type=amo.CONTRIB_REFUND, + transaction_id=67890) + tx0.update(created=datetime.date(2011, 12, 25)) + tx1.update(created=datetime.date(2012, 1, 1)) + self.txs = [tx0, tx1] + + def test_200(self): + r = self.client.get(self.url) + eq_(r.status_code, 200) + + def test_own_apps(self): + """Only user's transactions are shown.""" + app_factory() + r = RequestFactory().get(self.url) + r.user = self.user + transactions = _get_transactions(r)[1] + self.assertSetEqual([tx.addon for tx in transactions], self.apps) + + def test_filter(self): + """For each field in the form, run it through view and check results. + """ + tx0 = self.txs[0] + tx1 = self.txs[1] + + self.do_filter(self.txs) + + self.do_filter([tx0], app=tx0.id) + self.do_filter([tx1], app=tx1.id) + + self.do_filter([tx0], transaction_type=tx0.type) + self.do_filter([tx1], transaction_type=tx1.type) + + self.do_filter([tx0], transaction_id=tx0.transaction_id) + self.do_filter([tx1], transaction_id=tx1.transaction_id) + + self.do_filter(self.txs, date_from=datetime.date(2011, 12, 1)) + self.do_filter([tx1], date_from=datetime.date(2011, 12, 30), + date_to=datetime.date(2012, 2, 1)) + + def do_filter(self, expected_txs, **kw): + """Checks that filter returns the expected ids + + expected_ids -- list of app ids expected in the result. + """ + qs = _filter_transactions(Contribution.objects.all(), kw) + + self.assertSetEqual(qs.values_list('id', flat=True), + [tx.id for tx in expected_txs]) diff --git a/mkt/developers/urls.py b/mkt/developers/urls.py index 073a7bbdb74..db2cc6daac1 100644 --- a/mkt/developers/urls.py +++ b/mkt/developers/urls.py @@ -132,6 +132,8 @@ def bango_patterns(prefix): views.docs, name='mkt.developers.docs'), url('^statistics/', include(all_apps_stats_patterns)), + url('^transactions/', views.transactions, + name='mkt.developers.transactions'), # Bango-specific stuff. url('^bango/', include(bango_patterns('bango'))), diff --git a/mkt/developers/views.py b/mkt/developers/views.py index fac5077948d..f68cf3f1fdf 100644 --- a/mkt/developers/views.py +++ b/mkt/developers/views.py @@ -37,6 +37,7 @@ from files.utils import parse_addon from lib.cef_loggers import inapp_cef from market.models import Refund +from stats.models import Contribution from translations.models import delete_translation from users.models import UserProfile from users.views import _login @@ -49,7 +50,7 @@ AppFormSupport, AppFormTechnical, CategoryForm, ImageAssetFormSet, NewPackagedAppForm, PreviewFormSet, - trap_duplicate) + TransactionFilterForm, trap_duplicate) from mkt.developers.forms_payments import InappConfigForm from mkt.developers.utils import check_upload from mkt.inapp_pay.models import InappConfig @@ -467,7 +468,8 @@ def refresh_manifest(request, addon_id, addon, webapp=False): @json_view def _upload_manifest(request, is_standalone=False): form = forms.NewManifestForm(request.POST, is_standalone=is_standalone) - if not is_standalone and waffle.switch_is_active('webapps-unique-by-domain'): + if (not is_standalone and + waffle.switch_is_active('webapps-unique-by-domain')): # Helpful error if user already submitted the same manifest. dup_msg = trap_duplicate(request, request.POST.get('manifest')) if dup_msg: @@ -871,3 +873,39 @@ def blocklist(request, addon): messages.info(request, _('App already blocklisted.')) return redirect(addon.get_dev_url('versions')) + + +@waffle_switch('view-transactions') +@login_required +def transactions(request): + form, transactions = _get_transactions(request) + return jingo.render( + request, 'developers/transactions.html', + {'form': form, + 'CONTRIB_TYPES': amo.CONTRIB_TYPES, + 'count': transactions.count(), + 'transactions': amo.utils.paginate(request, + transactions, per_page=50)}) + + +def _get_transactions(request): + apps = addon_listing(request, webapp=True)[0] + transactions = Contribution.objects.filter(addon__in=list(apps)) + + form = TransactionFilterForm(request.GET, apps=apps) + if form.is_valid(): + transactions = _filter_transactions(transactions, form.cleaned_data) + return form, transactions + + +def _filter_transactions(qs, data): + """Handle search filters and queries for transactions.""" + filter_mapping = {'app': 'addon_id', + 'transaction_type': 'type', + 'transaction_id': 'transaction_id', + 'date_from': 'created__gte', + 'date_to': 'created__lte'} + for form_field, db_field in filter_mapping.iteritems(): + if data.get(form_field): + qs = qs.filter(**{db_field: data[form_field]}) + return qs diff --git a/mkt/reviewers/templates/reviewers/queue.html b/mkt/reviewers/templates/reviewers/queue.html index a40f535c483..793f768ad74 100644 --- a/mkt/reviewers/templates/reviewers/queue.html +++ b/mkt/reviewers/templates/reviewers/queue.html @@ -1,4 +1,5 @@ {% extends 'reviewers/base.html' %} +{% from 'site/helpers/form_row.html' import form_row %} {% block breadcrumbs %} {{ reviewers_breadcrumbs(queue=tab) }} @@ -88,26 +89,17 @@

{% endif %} diff --git a/mkt/site/templates/site/helpers/form_row.html b/mkt/site/templates/site/helpers/form_row.html new file mode 100644 index 00000000000..bcf76cfd262 --- /dev/null +++ b/mkt/site/templates/site/helpers/form_row.html @@ -0,0 +1,9 @@ +{% macro form_row(search_form, elems) -%} +
+ {% for elem in elems %} + {{ search_form[elem].label_tag() }} +
{{ search_form[elem] }}
+ {{ search_form[elem].errors }} + {% endfor %} +
+{%- endmacro %}